cosmic_files/
mime_app.rs

1// Copyright 2023 System76 <info@system76.com>
2// SPDX-License-Identifier: GPL-3.0-only
3
4use bstr::{BString, ByteSlice, ByteVec};
5use cosmic::widget;
6pub use mime_guess::Mime;
7#[cfg(feature = "desktop")]
8use notify_debouncer_full::notify;
9use rustc_hash::{FxHashMap, FxHashSet};
10use std::ffi::OsStr;
11use std::path::{Path, PathBuf};
12use std::sync::atomic::AtomicBool;
13use std::sync::{Arc, RwLock, atomic};
14#[cfg(feature = "desktop")]
15use std::time::{self, Instant};
16#[cfg(feature = "desktop")]
17use std::{fs, io};
18use std::process;
19
20#[cfg(feature = "desktop")]
21pub async fn watch(mut emitter: impl FnMut() + 'static + Send) {
22    let watcher_result = notify_debouncer_full::new_debouncer(
23        time::Duration::from_millis(250),
24        Some(time::Duration::from_millis(250)),
25        move |event_res: notify_debouncer_full::DebounceEventResult| {
26            let Ok(events) = event_res else {
27                return;
28            };
29
30            if events.iter().any(|event| {
31                event.kind.is_create() || event.kind.is_modify() || event.kind.is_remove()
32            }) {
33                emitter();
34            }
35        },
36    );
37
38    if let Ok(mut watcher) = watcher_result {
39        let system_paths = cosmic_mime_apps::list_paths();
40        let local_paths = (|| {
41            let base_dirs = xdg::BaseDirectories::new();
42            let Some(home) = base_dirs.get_config_home() else {
43                return Err(std::io::Error::other("XDG config home not set"));
44            };
45
46            let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") else {
47                return Err(std::io::Error::other("XDG_CURRENT_DESKTOP unset"));
48            };
49
50            let default_mimeapps = home.join("mimeapps.list");
51            let desktop_mimeapps =
52                home.join([&desktop.to_ascii_lowercase(), "-mimeapps.list"].concat());
53
54            Ok([desktop_mimeapps, default_mimeapps])
55        })()
56        .ok();
57
58        for path in system_paths
59            .iter()
60            .chain(local_paths.as_ref().into_iter().flatten())
61        {
62            _ = watcher.watch(path.as_path(), notify::RecursiveMode::NonRecursive);
63        }
64
65        std::future::pending().await
66    }
67}
68
69pub fn exec_to_command(
70    exec: &str,
71    entry_name: &str,
72    entry_path: Option<&Path>,
73    path_opt: &[impl AsRef<OsStr>],
74) -> Option<Vec<process::Command>> {
75    let arguments = shlex::split(exec)?;
76
77    if arguments.is_empty() {
78        tracing::error!("command does not contain any arguments");
79        return None;
80    }
81
82    let mut commands = Vec::new();
83
84    let paths = path_opt
85        .iter()
86        .map(AsRef::as_ref)
87        .map(Some)
88        // Add a single `None` if no path was given.
89        .chain(std::iter::repeat_n(
90            None,
91            if path_opt.is_empty() { 1 } else { 0 },
92        ));
93
94    for path in paths {
95        let mut batch_process = false;
96        let mut args = Vec::with_capacity(arguments.len());
97        let mut field_code_used = false;
98
99        for argument in arguments.iter().skip(1) {
100            let mut new_argument = BString::new(Vec::with_capacity(argument.capacity()));
101            let mut chars = argument.chars();
102            while let Some(char) = chars.next() {
103                // https://specifications.freedesktop.org/desktop-entry/latest/exec-variables.html
104                if char == '%' {
105                    match chars.next() {
106                        Some('%') => new_argument.push_char(char),
107                        Some('c') => new_argument.push_str(entry_name),
108                        Some('k') => {
109                            if let Some(path) = entry_path {
110                                new_argument.push_str(path.as_os_str().to_string_lossy().as_bytes());
111                            }
112                        }
113
114                        // %f and %u behave the same in a file manager.
115                        Some('f' | 'u') => {
116                            if let Some(path) = path
117                                && !field_code_used
118                            {
119                                // TODO: files on remote file systems should be copied to a temporary local file.
120                                batch_process = true;
121                                field_code_used = true;
122                                new_argument.push_str(path.to_string_lossy().as_bytes());
123                            }
124                        }
125
126                        // %F and %U behave the same in a file manager.
127                        Some('F') | Some('U') => {
128                            if !field_code_used && new_argument.is_empty() {
129                                field_code_used = true;
130                                for path in path_opt.iter().map(AsRef::as_ref) {
131                                    args.push(BString::new(path.to_string_lossy().into_owned().into_bytes()));
132                                }
133                            }
134                        }
135
136                        _ => (),
137                    }
138                } else {
139                    new_argument.push_char(char);
140                }
141            }
142
143            if !new_argument.is_empty() {
144                args.push(new_argument);
145            }
146        }
147
148        let mut command = process::Command::new(&arguments[0]);
149
150        for arg in args {
151            match arg.to_os_str() {
152                Ok(arg) => {
153                    command.arg(arg);
154                }
155                Err(_) => {
156                    tracing::error!("invalid string encoding in command");
157                    return None;
158                }
159            }
160        }
161
162        commands.push(command);
163
164        if !batch_process {
165            break;
166        }
167    }
168
169    #[cfg(debug_assertions)]
170    for command in &commands {
171        log::debug!(
172            "Parsed program {} with args: {:?}",
173            command.get_program().to_string_lossy(),
174            command.get_args()
175        );
176    }
177
178    Some(commands)
179}
180
181#[derive(Clone, Copy, Debug, Eq, PartialEq)]
182pub enum MimeAppMatch {
183    Exact,
184    Related,
185    Other,
186}
187
188#[derive(Clone, Debug)]
189pub struct MimeApp {
190    pub id: String,
191    pub path: Option<PathBuf>,
192    pub name: String,
193    pub exec: Option<String>,
194    icon_name: Box<str>,
195    icon: std::sync::OnceLock<widget::icon::Handle>,
196    is_default: Arc<RwLock<FxHashSet<Box<str>>>>,
197    no_display: Arc<AtomicBool>,
198}
199
200impl MimeApp {
201    //TODO: move to libcosmic, support multiple files
202    pub fn command<O: AsRef<OsStr>>(&self, path_opt: &[O]) -> Option<Vec<process::Command>> {
203        exec_to_command(
204            self.exec.as_deref()?,
205            &self.name,
206            self.path.as_deref(),
207            path_opt,
208        )
209    }
210
211    pub fn is_default(&self, mime: &Mime) -> bool {
212        self.is_default.read().unwrap().contains(mime.essence_str())
213    }
214
215    pub fn no_display(&self) -> bool {
216        self.no_display.load(atomic::Ordering::Relaxed)
217    }
218
219    pub fn icon(&self) -> widget::icon::Handle {
220        self.icon
221            .get_or_init(|| {
222                let name = &*self.icon_name;
223                if name.starts_with('/') {
224                    cosmic::widget::icon::from_path(PathBuf::from(name))
225                } else {
226                    cosmic::widget::icon::from_name(name).size(32).handle()
227                }
228            })
229            .clone()
230    }
231}
232
233// This allows usage of MimeApp in a dropdown
234impl AsRef<str> for MimeApp {
235    fn as_ref(&self) -> &str {
236        &self.name
237    }
238}
239
240pub struct MimeAppCache {
241    apps: Vec<Arc<MimeApp>>,
242    cache: FxHashMap<Mime, Vec<Arc<MimeApp>>>,
243    terminals: Vec<Arc<MimeApp>>,
244}
245
246impl MimeAppCache {
247    pub fn new() -> Self {
248        let mut mime_app_cache = Self {
249            apps: Vec::new(),
250            cache: FxHashMap::default(),
251            terminals: Vec::new(),
252        };
253        mime_app_cache.reload();
254        mime_app_cache
255    }
256
257    pub fn get_apps_for_mime(
258        &self,
259        mime_type: &Mime,
260        include_other: bool,
261    ) -> Vec<(&Arc<MimeApp>, MimeAppMatch)> {
262        let mut results = Vec::new();
263        let mut dedupe = FxHashSet::default();
264
265        // start with exact matches
266        results.extend(
267            self.get(mime_type)
268                .iter()
269                .filter(|&mime_app| dedupe.insert(&mime_app.id))
270                .map(|mime_app| (mime_app, MimeAppMatch::Exact)),
271        );
272
273        let include_mime = match mime_type.type_().as_str() {
274            "audio" => Some("video/mp4".parse::<Mime>().expect("video/mp4 mime")),
275            "text" => Some(mime_guess::mime::TEXT_PLAIN),
276            _ => None,
277        };
278
279        if let Some(mime) = include_mime {
280            results.extend(
281                self.get(&mime)
282                    .iter()
283                    .filter(|&mime_app| dedupe.insert(&mime_app.id))
284                    .map(|mime_app| (mime_app, MimeAppMatch::Exact)),
285            );
286        }
287
288        // grab matches based off of subclass / parent mime type
289        if let Some(parent_types) = crate::mime_icon::parent_mime_types(mime_type) {
290            for parent_type in parent_types {
291                results.extend(
292                    self.get(&parent_type)
293                        .iter()
294                        .filter(|&mime_app| dedupe.insert(&mime_app.id))
295                        .map(|mime_app| (mime_app, MimeAppMatch::Related)),
296                );
297            }
298        }
299
300        if include_other {
301            results.extend({
302                let mut apps = self
303                    .apps()
304                    .iter()
305                    .filter(|mime_app| !mime_app.no_display())
306                    .filter(|&mime_app| dedupe.insert(&mime_app.id))
307                    .map(|mime_app| (mime_app, MimeAppMatch::Other))
308                    .collect::<Vec<_>>();
309                apps.sort_by(|(a, _), (b, _)| {
310                    crate::localize::LANGUAGE_SORTER.compare(&a.name, &b.name)
311                });
312                apps
313            });
314        }
315
316        results
317    }
318
319    #[cfg(not(feature = "desktop"))]
320    pub fn reload(&mut self) {}
321
322    /// Reload mime types and their known app associations and defaults.
323    #[cfg(feature = "desktop")]
324    pub fn reload(&mut self) {
325        use crate::localize::LANGUAGE_SORTER;
326        use crate::mime_icon;
327        use cosmic::desktop::fde;
328        use std::borrow::Cow;
329
330        let start = Instant::now();
331
332        self.apps.clear();
333        self.cache.clear();
334        self.terminals.clear();
335
336        let mut list = cosmic_mime_apps::List::default();
337        let paths = cosmic_mime_apps::list_paths();
338        list.load_from_paths(&paths);
339        let locales = fde::get_languages_from_env();
340        let desktop_entries = fde::Iter::new(fde::default_paths()).entries(Some(&locales));
341        let mime_icon_cache = mime_icon::MIME_ICON_CACHE.lock().unwrap();
342        let shared_mime_info = &mime_icon_cache.shared_mime_info;
343        let mut aliased_mimes = FxHashMap::default();
344
345        for desktop_entry in desktop_entries {
346            let name = desktop_entry
347                .name(&locales)
348                .unwrap_or_else(|| Cow::Borrowed(desktop_entry.id()));
349
350            let app = Arc::new(MimeApp {
351                id: desktop_entry.appid.clone(),
352                path: Some(desktop_entry.path.clone()),
353                name: name.into(),
354                exec: desktop_entry.exec().map(String::from),
355                icon_name: desktop_entry.icon().unwrap_or_default().into(),
356                icon: std::sync::OnceLock::new(),
357                is_default: Arc::new(RwLock::default()),
358                no_display: Arc::new(AtomicBool::new(false)),
359            });
360
361            tracing::info!(target: "mime-apps", id = app.id, "detected desktop entry");
362
363            self.apps.push(app.clone());
364
365            if desktop_entry
366                .categories()
367                .into_iter()
368                .flatten()
369                .any(|c| c == "TerminalEmulator")
370            {
371                self.terminals.push(app.clone());
372            }
373
374            // Cache associations defined by the desktop entry.
375            let mime_types = desktop_entry.mime_type().unwrap_or_else(Vec::new);
376            let associated_mime_types = mime_types.iter().filter_map(|m| {
377                m.parse::<Mime>().ok().map(|mime| {
378                    if let Some(unaliased) = shared_mime_info.unalias_mime_type(&mime) {
379                        aliased_mimes.insert(unaliased.clone(), mime);
380                        return unaliased;
381                    }
382
383                    mime
384                })
385            });
386
387            for mime in associated_mime_types {
388                let apps = self.cache.entry(mime.clone()).or_default();
389                if apps.iter().all(|cached_app| cached_app.id != app.id) {
390                    apps.push(app.clone());
391                }
392            }
393        }
394
395        // Cache added associations from mimeapps lists.
396        for (mut added_mime, added_apps) in &list.added_associations {
397            let _unaliased;
398            if let Some(unaliased) = shared_mime_info.unalias_mime_type(added_mime) {
399                aliased_mimes.insert(unaliased.clone(), added_mime.clone());
400                _unaliased = unaliased;
401                added_mime = &_unaliased;
402            }
403
404            for added_app in added_apps {
405                if let Some(app) = self
406                    .apps
407                    .iter()
408                    .find(|cached| cached.id.as_str() == added_app.as_ref())
409                {
410                    let apps = self.cache.entry(added_mime.clone()).or_default();
411                    if apps.iter().all(|cached_app| cached_app.id != app.id) {
412                        apps.push(app.clone());
413                    }
414                }
415            }
416        }
417
418        // Remove associations
419        for (mut removed_mime, removed_apps) in &list.removed_associations {
420            let _unaliased;
421            if let Some(unaliased) = shared_mime_info.unalias_mime_type(removed_mime) {
422                aliased_mimes.insert(unaliased.clone(), removed_mime.clone());
423                _unaliased = unaliased;
424                removed_mime = &_unaliased;
425            }
426
427            for removed_app in removed_apps {
428                if let Some(app) = self
429                    .apps
430                    .iter()
431                    .find(|cached| cached.id.as_str() == removed_app.as_ref())
432                    && let Some(apps) = self.cache.get_mut(removed_mime)
433                {
434                    apps.retain(|cached_app| cached_app.id != app.id);
435                }
436            }
437        }
438
439        // Fetch defaults and sort apps by their default precedence.
440        for (mime, mut apps) in std::mem::take(&mut self.cache).into_iter() {
441            let defaults = list.default_app_for(&mime);
442            let aliased_defaults = aliased_mimes
443                .get(&mime)
444                .and_then(|mime| list.default_app_for(mime));
445
446            let cache = self
447                .cache
448                .entry(mime.clone())
449                .or_insert_with(|| Vec::with_capacity(apps.len()));
450
451            // Sort cached apps for this mime by default precedence.
452            for default in defaults
453                .into_iter()
454                .flatten()
455                .chain(aliased_defaults.into_iter().flatten())
456            {
457                let default = default.strip_suffix(".desktop").unwrap_or(default.as_ref());
458                let mut found_any = false;
459                apps.retain(|app| {
460                    let found = app.id.as_str() == default;
461                    if found {
462                        app.is_default
463                            .write()
464                            .unwrap()
465                            .insert(mime.essence_str().into());
466                        cache.push(app.clone());
467                        found_any = true;
468                    }
469
470                    !found
471                });
472
473                if !found_any && let Some(app) = self.apps.iter().find(|app| app.id == default) {
474                    app.is_default
475                        .write()
476                        .unwrap()
477                        .insert(mime.essence_str().into());
478                    cache.push(app.clone());
479                }
480            }
481
482            // Sort remaining apps by name
483            apps.sort_by(|a, b| LANGUAGE_SORTER.compare(&a.name, &b.name));
484            cache.extend_from_slice(&apps);
485
486            tracing::debug!(target: "mime-apps", mime = mime.essence_str(), apps = ?(cache.iter().map(|app| &*app.id).collect::<Vec<&str>>()), "mime defaults found")
487        }
488
489        let associated: rustc_hash::FxHashSet<&str> = self
490            .cache
491            .values()
492            .flatten()
493            .map(|app| app.id.as_str())
494            .collect();
495        for app in &self.apps {
496            app.no_display.store(
497                !associated.contains(app.id.as_str()),
498                atomic::Ordering::Relaxed,
499            );
500        }
501
502        let elapsed = start.elapsed();
503        tracing::info!(target: "mime-apps", "loaded mime app cache in {elapsed:?}");
504    }
505
506    pub fn apps(&self) -> &[Arc<MimeApp>] {
507        &self.apps
508    }
509
510    pub fn get(&self, key: &Mime) -> &[Arc<MimeApp>] {
511        self.cache.get(key).map_or(&[], Vec::as_slice)
512    }
513
514    pub fn icons(&self, key: &Mime) -> Vec<widget::icon::Handle> {
515        self.cache
516            .get(key)
517            .map_or_else(Vec::new, |apps| apps.iter().map(|app| app.icon()).collect())
518    }
519
520    fn get_default_terminal(&self) -> Option<String> {
521        let output = process::Command::new("xdg-mime")
522            .args(["query", "default", "x-scheme-handler/terminal"])
523            .output()
524            .ok()?;
525
526        if !output.status.success() {
527            return None;
528        }
529
530        String::from_utf8(output.stdout)
531            .ok()
532            .map(|string| string.trim().replace(".desktop", ""))
533    }
534
535    pub fn terminal(&self) -> Option<&Arc<MimeApp>> {
536        //TODO: consider rules in https://github.com/Vladimir-csp/xdg-terminal-exec
537        // The current approach works but might not adhere to the spec (yet)
538
539        // Look for and return preferred terminals
540        //TODO: fallback order beyond cosmic-term?
541
542        let mut preference_order = vec!["com.system76.CosmicTerm".to_string()];
543
544        if let Some(id) = self.get_default_terminal() {
545            preference_order.insert(0, id);
546        }
547
548        for id in &preference_order {
549            for terminal in &self.terminals {
550                if &terminal.id == id {
551                    return Some(terminal);
552                }
553            }
554        }
555
556        // Return whatever was the first terminal found
557        self.terminals.first()
558    }
559
560    #[cfg(not(feature = "desktop"))]
561    pub fn set_default(&mut self, mime: Mime, id: String) {
562        log::warn!(
563            "failed to set default handler for {mime:?} to {id:?}: desktop feature not enabled"
564        );
565    }
566
567    #[cfg(feature = "desktop")]
568    pub fn set_default(&mut self, mime: Mime, mut id: String) {
569        let Some(path) = cosmic_mime_apps::local_list_path() else {
570            log::warn!("failed to find mimeapps.list path");
571            return;
572        };
573
574        let mut list = cosmic_mime_apps::List::default();
575        match fs::read_to_string(&path) {
576            Ok(string) => {
577                list.load_from(&string);
578            }
579            Err(err) => {
580                if err.kind() != io::ErrorKind::NotFound {
581                    log::warn!("failed to read {}: {}", path.display(), err);
582                    return;
583                }
584            }
585        }
586
587        let suffix = ".desktop";
588        if !id.ends_with(suffix) {
589            id.push_str(suffix);
590        }
591        list.set_default_app(mime, id);
592
593        let mut string = list.to_string();
594        string.push('\n');
595        match fs::write(&path, string) {
596            Ok(()) => {
597                self.reload();
598            }
599            Err(err) => {
600                log::warn!("failed to write {}: {}", path.display(), err);
601            }
602        }
603    }
604}
605
606impl Default for MimeAppCache {
607    fn default() -> Self {
608        Self::new()
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::exec_to_command;
615
616    #[test]
617    fn keys_within_words() {
618        let exec = "/usr/bin/foo --option=%f";
619        let paths = ["file1"];
620        let commands = exec_to_command(exec, "keys_within_words", None, &paths)
621            .expect("Should parse valid exec");
622
623        assert_eq!(1, commands.len());
624        let command = commands.first().unwrap();
625
626        assert_eq!("/usr/bin/foo", command.get_program().to_str().unwrap());
627        assert_eq!(
628            "--option=file1",
629            command.get_args().next().unwrap().to_str().unwrap()
630        );
631    }
632
633    #[test]
634    fn no_path_f_field_code() {
635        let exec = "/usr/bin/foo %f";
636        let paths: [&str; 0] = [];
637        let commands = exec_to_command(exec, "no_path_f_field_code", None, &paths)
638            .expect("Should parse valid exec");
639
640        assert_eq!(1, commands.len());
641        let command = commands.first().unwrap();
642
643        assert_eq!("/usr/bin/foo", command.get_program().to_str().unwrap());
644        assert_eq!(0, command.get_args().len());
645    }
646
647    #[test]
648    fn one_path_f_field_code() {
649        let exec = "/usr/bin/foo %f";
650        let paths = ["file1"];
651        let commands = exec_to_command(exec, "one_path_f_field_code", None, &paths)
652            .expect("Should parse valid exec");
653
654        assert_eq!(1, commands.len());
655        let command = commands.first().unwrap();
656
657        assert_eq!("/usr/bin/foo", command.get_program().to_str().unwrap());
658        assert_eq!(
659            "file1",
660            command.get_args().next().unwrap().to_str().unwrap()
661        );
662    }
663
664    #[test]
665    #[allow(non_snake_case)]
666    fn one_path_F_field_code() {
667        let exec = "/usr/bin/cosmic-term -w %F";
668        let paths = ["/home/user"];
669        let commands = exec_to_command(exec, "one_path_F_field_code", None, &paths)
670            .expect("Should parse valid exec");
671
672        assert_eq!(1, commands.len());
673        let command = commands.first().unwrap();
674        let mut args = command.get_args();
675
676        assert_eq!(
677            "/usr/bin/cosmic-term",
678            command.get_program().to_str().unwrap()
679        );
680        assert_eq!("-w", args.next().unwrap().to_str().unwrap());
681        assert_eq!(paths[0], args.next().unwrap().to_str().unwrap());
682    }
683
684    #[test]
685    fn one_path_u_field_code() {
686        let exec = "/usr/bin/cosmic-term -w %u";
687        let paths = ["/home/user"];
688        let commands = exec_to_command(exec, "one_path_u_field_code", None, &paths)
689            .expect("Should parse valid exec");
690
691        assert_eq!(1, commands.len());
692        let command = commands.first().unwrap();
693        let mut args = command.get_args();
694
695        assert_eq!(
696            "/usr/bin/cosmic-term",
697            command.get_program().to_str().unwrap()
698        );
699        assert_eq!("-w", args.next().unwrap().to_str().unwrap());
700        assert_eq!(paths[0], args.next().unwrap().to_str().unwrap());
701    }
702
703    #[test]
704    #[allow(non_snake_case)]
705    fn one_path_U_field_code() {
706        let exec = "/usr/bin/rmrfbye %U";
707        let paths = ["/"];
708        let commands = exec_to_command(exec, "one_path_U_field_code", None, &paths)
709            .expect("Should parse valid exec");
710
711        assert_eq!(1, commands.len());
712        let command = commands.first().unwrap();
713
714        assert_eq!("/usr/bin/rmrfbye", command.get_program().to_str().unwrap());
715        assert_eq!("/", command.get_args().next().unwrap().to_str().unwrap());
716    }
717
718    #[test]
719    fn mult_path_f_field_code() {
720        let exec = "/usr/games/ppsspp %f";
721        let paths = [
722            "/usr/share/games/psp/miku.iso",
723            "/usr/share/games/psp/eternia.iso",
724        ];
725        let commands = exec_to_command(exec, "mult_path_f_field_code", None, &paths)
726            .expect("Should parse valid exec");
727
728        assert_eq!(paths.len(), commands.len());
729        for (command, path) in commands.into_iter().zip(paths.iter()) {
730            assert_eq!("/usr/games/ppsspp", command.get_program().to_str().unwrap());
731
732            assert_eq!(1, command.get_args().len());
733            let command_path = command.get_args().next().unwrap();
734            assert_eq!(*path, command_path.to_str().unwrap());
735        }
736    }
737
738    #[test]
739    #[allow(non_snake_case)]
740    fn mult_path_F_field_code() {
741        let exec = "/usr/games/gzdoom %F";
742        let paths = [
743            "/usr/share/games/doom2/hr.wad",
744            "/usr/share/games/doom2/hrmus.wad",
745        ];
746        let commands = exec_to_command(exec, "mult_path_F_field_code", None, &paths)
747            .expect("Should parse valid exec");
748
749        assert_eq!(1, commands.len());
750        let command = commands.first().unwrap();
751
752        assert_eq!("/usr/games/gzdoom", command.get_program().to_str().unwrap());
753        assert!(
754            paths
755                .iter()
756                .zip(command.get_args())
757                .all(|(&expected, actual)| expected == actual.to_string_lossy())
758        );
759    }
760
761    #[test]
762    fn mult_path_u_field_code() {
763        let exec = "/usr/bin/cosmic_browser %u";
764        let paths = [
765            "file:///home/josh/Books/osstep.pdf",
766            "https://redox-os.org/",
767            "https://system76.com/",
768        ];
769        let commands = exec_to_command(exec, "mult_path_u_field_code", None, &paths)
770            .expect("Should parse valid exec");
771
772        assert_eq!(paths.len(), commands.len());
773        for (command, path) in commands.into_iter().zip(paths.iter()) {
774            assert_eq!(
775                "/usr/bin/cosmic_browser",
776                command.get_program().to_str().unwrap()
777            );
778
779            assert_eq!(1, command.get_args().len());
780            let command_path = command.get_args().next().unwrap();
781            assert_eq!(*path, command_path.to_str().unwrap());
782        }
783    }
784
785    #[test]
786    #[allow(non_snake_case)]
787    fn mult_path_U_field_code() {
788        let exec = "/usr/bin/mpv %U";
789        let paths = [
790            "frieren01.mkv",
791            "rtmp://example.org/this/video/doesnt/exist.avi",
792        ];
793        let commands = exec_to_command(exec, "mult_path_U_field_code", None, &paths)
794            .expect("Should parse valid exec");
795
796        assert_eq!(1, commands.len());
797        let command = commands.first().unwrap();
798        assert_eq!(paths.len(), command.get_args().count());
799
800        assert_eq!("/usr/bin/mpv", command.get_program().to_str().unwrap());
801        assert!(
802            paths
803                .iter()
804                .zip(command.get_args())
805                .all(|(&expected, actual)| expected == actual.to_string_lossy())
806        );
807    }
808
809    #[test]
810    fn flatpak_style_exec() {
811        // Tests args before field codes
812        let exec = "/usr/bin/flatpak run --branch=stable --command=ferris --file-forwarding org.joshfake.ferris @@u %U";
813        let args = [
814            "run",
815            "--branch=stable",
816            "--command=ferris",
817            "--file-forwarding",
818            "org.joshfake.ferris",
819            "@@u",
820        ];
821        let paths = ["file1.rs", "file2.rs"];
822        let commands = exec_to_command(exec, "flatpak_style_exec", None, &paths)
823            .expect("Should parse valid exec");
824
825        assert_eq!(1, commands.len());
826        let command = commands.first().unwrap();
827        assert_eq!(args.len() + paths.len(), command.get_args().count());
828
829        assert_eq!("/usr/bin/flatpak", command.get_program().to_str().unwrap());
830        assert!(
831            args.iter()
832                .chain(paths.iter())
833                .zip(command.get_args())
834                .all(|(&expected, actual)| expected == actual.to_string_lossy())
835        );
836    }
837
838    #[test]
839    fn multiple_field_codes() {
840        // Tests that only one field code is used rather than passing paths to each field code
841        let exec = "/usr/games/roguelike %U %f";
842        let paths = [
843            "file:///usr/share/games/roguelike/mods/mod1",
844            "file:///usr/share/games/roguelike/mods/mod2",
845        ];
846        let commands = exec_to_command(exec, "multiple_field_codes", None, &paths)
847            .expect("Should parse valid exec");
848
849        assert_eq!(1, commands.len());
850        let command = commands.first().unwrap();
851
852        assert_eq!(
853            "/usr/games/roguelike",
854            command.get_program().to_str().unwrap()
855        );
856        assert!(
857            paths
858                .iter()
859                .zip(command.get_args())
860                .all(|(&expected, actual)| expected == actual.to_string_lossy())
861        );
862    }
863
864    #[test]
865    fn sandwiched_field_code() {
866        // Tests that arguments before and after the field code works
867        // (Borrowed from KDE because someone had this exact line in an issue)
868        let exec = "/usr/bin/flatpak run --branch=stable --arch=x86_64 --command=okular --file-forwarding org.kde.okular @@u %U @@";
869        let args_leading = [
870            "run",
871            "--branch=stable",
872            "--arch=x86_64",
873            "--command=okular",
874            "--file-forwarding",
875            "org.kde.okular",
876            "@@u",
877        ];
878        let paths = ["rust_game_dev.pdf", "superhero_ferris.epub"];
879        let args_trailing = ["@@"];
880        let commands = exec_to_command(exec, "sandwiched_field_code", None, &paths)
881            .expect("Should parse valid exec");
882
883        assert_eq!(1, commands.len());
884        let command = commands.first().unwrap();
885        assert_eq!(
886            args_leading.len() + paths.len() + args_trailing.len(),
887            command.get_args().len()
888        );
889
890        assert_eq!("/usr/bin/flatpak", command.get_program().to_str().unwrap());
891        assert!(
892            args_leading
893                .iter()
894                .chain(paths.iter())
895                .chain(args_trailing.iter())
896                .zip(command.get_args())
897                .all(|(&expected, actual)| expected == actual.to_string_lossy())
898        );
899    }
900}