1use 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 .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 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 Some('f' | 'u') => {
116 if let Some(path) = path
117 && !field_code_used
118 {
119 batch_process = true;
121 field_code_used = true;
122 new_argument.push_str(path.to_string_lossy().as_bytes());
123 }
124 }
125
126 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 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
233impl 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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}