1use common::Result;
2use std::collections::HashMap;
3use std::fs;
4use std::os::unix::fs::PermissionsExt;
5use std::path::{Path, PathBuf};
6
7#[derive(Clone)]
9pub struct DesktopApp {
10 pub name: String,
11 pub exec: String,
12 pub is_cli: bool,
13}
14
15fn desktop_dirs() -> Vec<PathBuf> {
18 let mut dirs: Vec<PathBuf> = [
19 "/usr/share/applications",
20 "/usr/local/share/applications",
21 "/var/lib/flatpak/exports/share/applications",
22 ]
23 .iter()
24 .map(PathBuf::from)
25 .collect();
26 dirs.extend(dirs::data_dir().map(|d| d.join("applications")));
27 dirs
28}
29
30fn desktop_entries() -> Vec<Entry> {
32 let mut out = Vec::new();
33 for dir in desktop_dirs() {
34 if let Ok(entries) = fs::read_dir(&dir) {
35 for entry in entries.flatten() {
36 let path = entry.path();
37 if path.extension().is_some_and(|e| e == "desktop") {
38 if let Some(e) = read_entry(&path) {
39 out.push(e);
40 }
41 }
42 }
43 }
44 }
45 out
46}
47
48pub fn list_applications() -> Result<Vec<DesktopApp>> {
50 let mut apps: Vec<DesktopApp> = desktop_entries()
51 .into_iter()
52 .filter_map(|e| {
53 Some(DesktopApp {
54 exec: exec_command(&e.exec)?,
55 name: e.name,
56 is_cli: false,
57 })
58 })
59 .collect();
60 apps.sort_by_key(|a| a.name.to_lowercase());
61 apps.dedup_by(|a, b| a.name == b.name);
62 Ok(apps)
63}
64
65#[derive(Debug, Default, Clone, PartialEq, Eq)]
67pub struct DesktopNames {
68 pub programs: HashMap<String, String>,
71 pub dirs: HashMap<PathBuf, String>,
75}
76
77pub fn installed_names() -> DesktopNames {
79 let entries = desktop_entries();
80 DesktopNames {
81 programs: names_from_entries(&entries),
82 dirs: dirs_from_entries(&entries, resolve_program, dirs::home_dir().as_deref()),
83 }
84}
85
86fn names_from_entries(entries: &[Entry]) -> HashMap<String, String> {
94 let mut by_program: HashMap<String, Vec<&Entry>> = HashMap::new();
95 for e in entries {
96 let Some(program) = own_program(e) else {
97 continue;
98 };
99 let program = basename(&program);
100 if !program.is_empty() {
101 by_program.entry(program).or_default().push(e);
102 }
103 }
104 let mut names = HashMap::new();
105 for (program, entries) in by_program {
106 let name = single_name(entries.iter().copied()).or_else(|| {
107 single_name(
108 entries
109 .iter()
110 .copied()
111 .filter(|e| entry_matches(e, &program)),
112 )
113 });
114 if let Some(name) = name {
115 names.insert(program, name);
116 }
117 }
118 names
119}
120
121fn dirs_from_entries(
130 entries: &[Entry],
131 resolve: impl Fn(&str) -> Option<PathBuf>,
132 home: Option<&Path>,
133) -> HashMap<PathBuf, String> {
134 let mut by_dir: HashMap<PathBuf, Vec<&Entry>> = HashMap::new();
135 for e in entries {
136 let Some(file) = own_program(e).and_then(|p| resolve(&p)) else {
137 continue;
138 };
139 let (Some(dir), Some(name)) = (file.parent(), file.file_name().and_then(|n| n.to_str()))
140 else {
141 continue;
142 };
143 if is_launcher(name) || name.to_lowercase().ends_with(".appimage") {
144 continue;
145 }
146 if !is_shared_dir(dir, home) {
147 by_dir.entry(dir.to_path_buf()).or_default().push(e);
148 }
149 }
150 by_dir
151 .into_iter()
152 .filter_map(|(dir, entries)| Some((dir, single_name(entries.into_iter())?)))
153 .collect()
154}
155
156fn resolve_program(program: &str) -> Option<PathBuf> {
159 if program.contains('/') {
160 return fs::canonicalize(program).ok();
161 }
162 find_on_path(program, std::env::split_paths(&std::env::var_os("PATH")?))
163}
164
165fn find_on_path(program: &str, dirs: impl Iterator<Item = PathBuf>) -> Option<PathBuf> {
169 dirs.filter(|dir| dir.is_absolute())
170 .filter_map(|dir| fs::canonicalize(dir.join(program)).ok())
171 .find(|file| file.is_file())
172}
173
174fn is_shared_dir(dir: &Path, home: Option<&Path>) -> bool {
178 const SHARED: &[&str] = &[
179 "/",
180 "/bin",
181 "/sbin",
182 "/lib",
183 "/lib64",
184 "/opt",
185 "/usr/bin",
186 "/usr/sbin",
187 "/usr/games",
188 "/usr/lib",
189 "/usr/lib32",
190 "/usr/lib64",
191 "/usr/libexec",
192 "/usr/share",
193 "/usr/local/bin",
194 "/usr/local/sbin",
195 "/usr/local/games",
196 "/usr/local/lib",
197 "/usr/local/libexec",
198 "/snap/bin",
199 "/var/lib/flatpak/exports/bin",
200 "/app/bin",
201 "/run/current-system/sw/bin",
202 "/usr/local/go/bin",
203 ];
204 const SHARED_IN_HOME: &[&str] = &[
205 "",
206 "bin",
207 ".local/bin",
208 ".cargo/bin",
209 ".nix-profile/bin",
210 ".local/share/flatpak/exports/bin",
211 "Applications",
212 "Downloads",
213 "Desktop",
214 "go/bin",
215 ".bun/bin",
216 ".deno/bin",
217 ".npm-global/bin",
218 ".volta/bin",
219 ".local/share/pnpm",
220 ".dotnet/tools",
221 ];
222 if SHARED.iter().any(|s| dir == Path::new(s)) {
223 return true;
224 }
225 if dir.file_name().is_some_and(|n| n == "bin" || n == "sbin") {
226 return true;
227 }
228 if home.is_some_and(|h| dir.parent() == Some(h)) {
229 return true;
230 }
231 let multiarch = dir
233 .parent()
234 .is_some_and(|p| p == Path::new("/usr/lib") || p == Path::new("/lib"))
235 && dir
236 .file_name()
237 .and_then(|n| n.to_str())
238 .is_some_and(|n| n.contains("-linux-"));
239 multiarch || home.is_some_and(|h| SHARED_IN_HOME.iter().any(|s| dir == h.join(s)))
240}
241
242fn own_program(e: &Entry) -> Option<String> {
247 let args = exec_args(&e.exec)?;
248 let program = program_arg(&args)?;
249 if is_launcher(&basename(program)) || args.iter().any(|a| launches_something_else(a)) {
250 return None;
251 }
252 Some(program.to_string())
253}
254
255fn single_name<'a>(entries: impl Iterator<Item = &'a Entry>) -> Option<String> {
257 let mut names = entries.map(|e| e.name.as_str());
258 let first = names.next()?;
259 names.all(|n| n == first).then(|| first.to_string())
260}
261
262fn entry_matches(e: &Entry, program: &str) -> bool {
266 let stem = e.stem.to_lowercase();
267 let program = program.to_lowercase();
268 stem == program
269 || stem.rsplit('.').next() == Some(program.as_str())
270 || e.wm_class
271 .as_deref()
272 .is_some_and(|c| c.eq_ignore_ascii_case(&program))
273}
274
275fn launches_something_else(arg: &str) -> bool {
278 arg == "-e"
279 || arg.starts_with("--app-id")
280 || arg.starts_with("--app=")
281 || arg.starts_with("--application-mode")
282 || arg.starts_with("steam://")
283 || arg.starts_with("lutris:")
284}
285
286pub(crate) fn is_launcher(program: &str) -> bool {
291 const RUNTIMES: &[&str] = &[
292 "java", "javaw", "node", "nodejs", "electron", "python", "perl", "ruby", "php", "lua",
293 "mono", "dotnet", "wine", "gjs", "bun", "deno",
294 ];
295 const EXACT: &[&str] = &[
296 "sh",
297 "bash",
298 "dash",
299 "zsh",
300 "fish",
301 "env",
302 "java",
303 "node",
304 "nodejs",
305 "electron",
306 "flatpak",
307 "snap",
308 "gjs",
309 "gjs-console",
310 "perl",
311 "ruby",
312 "php",
313 "mono",
314 "dotnet",
315 "wine",
316 "wine64",
317 "xdg-open",
318 "gio",
319 ];
320 let unversioned =
321 program.trim_end_matches(|c: char| c.is_ascii_digit() || matches!(c, '.' | '-' | '_'));
322 EXACT.contains(&program) || RUNTIMES.contains(&unversioned) || program.starts_with("python")
323}
324
325pub fn exec_program(value: &str) -> Option<String> {
328 program_of(&exec_args(value)?)
329}
330
331fn exec_args(value: &str) -> Option<Vec<String>> {
334 Some(
335 split_exec(&unescape_value(value))?
336 .into_iter()
337 .filter_map(expand_field_codes)
338 .collect(),
339 )
340}
341
342fn program_of(args: &[String]) -> Option<String> {
344 let base = basename(program_arg(args)?);
345 (!base.is_empty()).then_some(base)
346}
347
348fn basename(program: &str) -> String {
350 program.rsplit('/').next().unwrap_or(program).to_string()
351}
352
353fn program_arg(args: &[String]) -> Option<&str> {
355 let mut args = args.iter();
356 let mut program = args.next()?;
357 if program == "env" {
358 program = loop {
359 let arg = args.next()?;
360 match arg.as_str() {
361 "-u" | "--unset" | "-C" | "--chdir" => {
362 args.next();
363 }
364 "--" => break args.next()?,
365 a if a.starts_with('-') || a.contains('=') => {}
366 _ => break arg,
367 }
368 };
369 }
370 Some(program.as_str())
371}
372
373struct Entry {
376 name: String,
377 exec: String,
378 stem: String,
379 wm_class: Option<String>,
380}
381
382fn read_entry(path: &Path) -> Option<Entry> {
385 let content = fs::read_to_string(path).ok()?;
386 let mut name = None;
387 let mut exec = None;
388 let mut wm_class = None;
389 let mut no_display = false;
390 let mut in_desktop_entry = false;
391
392 for line in content.lines() {
393 let line = line.trim();
394
395 if line.starts_with('[') {
396 in_desktop_entry = line == "[Desktop Entry]";
397 continue;
398 }
399
400 if !in_desktop_entry {
401 continue;
402 }
403
404 if let Some(value) = line.strip_prefix("Name=") {
405 if name.is_none() && !value.trim().is_empty() {
406 name = Some(value.to_string());
407 }
408 } else if let Some(value) = line.strip_prefix("Exec=") {
409 if exec.is_none() && exec_command(value).is_some() {
410 exec = Some(value.to_string());
411 }
412 } else if let Some(value) = line.strip_prefix("StartupWMClass=") {
413 wm_class = Some(value.trim().to_string());
414 } else if line == "NoDisplay=true" || line == "Hidden=true" {
415 no_display = true;
416 } else if line.starts_with("Type=") && line != "Type=Application" {
417 return None;
418 }
419 }
420
421 if no_display {
422 return None;
423 }
424
425 Some(Entry {
426 name: name?,
427 exec: exec?,
428 stem: path
429 .file_stem()
430 .map(|s| s.to_string_lossy().into_owned())
431 .unwrap_or_default(),
432 wm_class,
433 })
434}
435
436pub fn exec_command(value: &str) -> Option<String> {
443 let args = split_exec(&unescape_value(value))?;
444 let args: Vec<String> = args.into_iter().filter_map(expand_field_codes).collect();
445 if args.is_empty() {
446 return None;
447 }
448 if args[0] == "env" && !env_runs_a_program(&args[1..]) {
449 return None;
450 }
451 Some(
452 args.iter()
453 .map(|a| shell_quote(a))
454 .collect::<Vec<_>>()
455 .join(" "),
456 )
457}
458
459fn env_runs_a_program(args: &[String]) -> bool {
463 let mut args = args.iter();
464 while let Some(arg) = args.next() {
465 match arg.as_str() {
466 "-u" | "--unset" | "-C" | "--chdir" => {
468 args.next();
469 }
470 "-S" | "--split-string" => return args.next().is_some(),
472 "--" => return args.next().is_some(),
473 a if a.starts_with("-S") && a.len() > 2 => return true,
474 a if a.starts_with('-') => {}
475 a if a.contains('=') => {}
476 _ => return true,
477 }
478 }
479 false
480}
481
482fn unescape_value(value: &str) -> String {
485 let mut out = String::with_capacity(value.len());
486 let mut chars = value.chars();
487 while let Some(c) = chars.next() {
488 if c != '\\' {
489 out.push(c);
490 continue;
491 }
492 match chars.next() {
493 Some('s') => out.push(' '),
494 Some('n') => out.push('\n'),
495 Some('t') => out.push('\t'),
496 Some('r') => out.push('\r'),
497 Some('\\') => out.push('\\'),
498 Some(other) => {
499 out.push('\\');
500 out.push(other);
501 }
502 None => out.push('\\'),
503 }
504 }
505 out
506}
507
508fn split_exec(value: &str) -> Option<Vec<String>> {
512 let mut args = Vec::new();
513 let mut current = String::new();
514 let mut in_arg = false;
515 let mut chars = value.chars();
516 while let Some(c) = chars.next() {
517 match c {
518 '"' => {
519 in_arg = true;
520 loop {
521 match chars.next()? {
522 '"' => break,
523 '\\' => match chars.next()? {
524 e @ ('"' | '`' | '$' | '\\') => current.push(e),
525 other => {
526 current.push('\\');
527 current.push(other);
528 }
529 },
530 other => current.push(other),
531 }
532 }
533 }
534 c if c.is_whitespace() => {
535 if in_arg {
536 args.push(std::mem::take(&mut current));
537 in_arg = false;
538 }
539 }
540 c => {
541 in_arg = true;
542 current.push(c);
543 }
544 }
545 }
546 if in_arg {
547 args.push(current);
548 }
549 Some(args)
550}
551
552fn expand_field_codes(arg: String) -> Option<String> {
556 if arg.len() == 2 && arg.starts_with('%') && arg != "%%" {
557 return None;
558 }
559 let mut out = String::with_capacity(arg.len());
560 let mut chars = arg.chars();
561 while let Some(c) = chars.next() {
562 if c != '%' {
563 out.push(c);
564 } else if let Some(next) = chars.next() {
565 if next == '%' {
566 out.push('%');
567 }
568 }
569 }
570 Some(out)
571}
572
573fn shell_quote(arg: &str) -> String {
576 let plain = !arg.is_empty()
577 && arg
578 .chars()
579 .all(|c| c.is_ascii_alphanumeric() || "_@%+=:,./-".contains(c));
580 if plain {
581 arg.to_string()
582 } else {
583 format!("'{}'", arg.replace('\'', r"'\''"))
584 }
585}
586
587pub fn search_cli_apps(query: &str) -> Vec<DesktopApp> {
589 if query.len() < 2 {
590 return Vec::new();
591 }
592
593 let query_lower = query.to_lowercase();
594 let mut apps = Vec::new();
595
596 if let Ok(path_var) = std::env::var("PATH") {
597 for dir in path_var.split(':') {
598 let dir_path = Path::new(dir);
599 if let Ok(entries) = fs::read_dir(dir_path) {
600 for entry in entries.flatten() {
601 let name = entry.file_name().to_string_lossy().to_string();
602 if !name.to_lowercase().contains(&query_lower) {
603 continue;
604 }
605
606 if let Ok(meta) = entry.metadata() {
608 if meta.is_file() && (meta.permissions().mode() & 0o111 != 0) {
609 apps.push(DesktopApp {
610 name: format!("{} (CLI)", name),
611 exec: shell_quote(&name),
615 is_cli: true,
616 });
617 }
618 }
619 }
620 }
621 }
622 }
623
624 apps.sort_by(|a, b| a.name.cmp(&b.name));
625 apps.dedup_by(|a, b| a.exec == b.exec);
626 apps
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632
633 #[test]
634 fn exec_drops_field_codes_and_keeps_arguments() {
635 assert_eq!(exec_command("firefox %u").as_deref(), Some("firefox"));
636 assert_eq!(
637 exec_command("code --new-window %F").as_deref(),
638 Some("code --new-window")
639 );
640 assert_eq!(
641 exec_command("app --pct=100%%").as_deref(),
642 Some("app --pct=100%")
643 );
644 assert_eq!(
645 exec_command("app --file=%f").as_deref(),
646 Some("app --file=")
647 );
648 }
649
650 #[test]
651 fn exec_keeps_quoted_arguments_together() {
652 assert_eq!(
653 exec_command(r#""/opt/My App/app" --flag %U"#).as_deref(),
654 Some("'/opt/My App/app' --flag")
655 );
656 assert_eq!(
657 exec_command(r#"sh -c "echo hi; sleep 1""#).as_deref(),
658 Some("sh -c 'echo hi; sleep 1'")
659 );
660 assert_eq!(
663 exec_command(r#"app "say \\"hi\\"" "c:\\\\dir""#).as_deref(),
664 Some(r#"app 'say "hi"' 'c:\dir'"#)
665 );
666 assert_eq!(
667 exec_command("app \"it's\"").as_deref(),
668 Some(r"app 'it'\''s'")
669 );
670 assert_eq!(exec_command(r"my\sapp").as_deref(), Some("my app"));
672 }
673
674 #[test]
675 fn exec_keeps_env_wrappers_and_their_variables() {
676 assert_eq!(
677 exec_command("env FOO=1 BAR=\"a b\" app --x %f").as_deref(),
678 Some("env FOO=1 'BAR=a b' app --x")
679 );
680 assert_eq!(
681 exec_command("env -u GTK_THEME app").as_deref(),
682 Some("env -u GTK_THEME app")
683 );
684 assert_eq!(
685 exec_command("env -i -- app").as_deref(),
686 Some("env -i -- app")
687 );
688 assert_eq!(exec_command("env -u X"), None);
689 assert_eq!(exec_command("env -i"), None);
690 }
691
692 #[test]
693 fn cli_app_names_are_quoted() {
694 assert_eq!(shell_quote("my tool"), "'my tool'");
695 assert_eq!(shell_quote("rg"), "rg");
696 assert_eq!(shell_quote("a$b"), "'a$b'");
697 }
698
699 #[test]
700 fn exec_rejects_empty_or_unterminated_lines() {
701 assert_eq!(exec_command("%U"), None);
702 assert_eq!(exec_command(" "), None);
703 assert_eq!(exec_command("env A=1"), None);
704 assert_eq!(exec_command(r#"app "open"#), None);
705 }
706
707 #[test]
708 fn exec_program_is_the_basename_past_env() {
709 assert_eq!(exec_program("firefox %u").as_deref(), Some("firefox"));
710 assert_eq!(
711 exec_program("/usr/bin/google-chrome-stable %U").as_deref(),
712 Some("google-chrome-stable")
713 );
714 assert_eq!(
715 exec_program(r#""/opt/My App/app" --flag"#).as_deref(),
716 Some("app")
717 );
718 assert_eq!(
719 exec_program("env FOO=1 -u X /snap/bin/code --new-window").as_deref(),
720 Some("code")
721 );
722 assert_eq!(exec_program("env -i -- app").as_deref(), Some("app"));
723 assert_eq!(exec_program("env A=1"), None);
724 assert_eq!(exec_program("%U"), None);
725 }
726
727 #[test]
728 fn an_empty_name_is_ignored() {
729 let dir = tempfile::tempdir().unwrap();
730 let path = dir.path().join("x.desktop");
731 fs::write(&path, "[Desktop Entry]\nType=Application\nName=\nExec=x\n").unwrap();
732 assert!(read_entry(&path).is_none());
733 fs::write(&path, "[Desktop Entry]\nType=Application\nName=X\nExec=x\n").unwrap();
734 assert_eq!(read_entry(&path).map(|e| e.name).as_deref(), Some("X"));
735 }
736
737 fn entry(stem: &str, name: &str, exec: &str, wm_class: Option<&str>) -> Entry {
738 Entry {
739 name: name.into(),
740 exec: exec.into(),
741 stem: stem.into(),
742 wm_class: wm_class.map(Into::into),
743 }
744 }
745
746 #[test]
747 fn a_single_entry_names_its_program() {
748 let names = names_from_entries(&[entry("firefox", "Firefox", "firefox %u", None)]);
749 assert_eq!(names.get("firefox").map(String::as_str), Some("Firefox"));
750 let names = names_from_entries(&[entry(
751 "org.mozilla.firefox",
752 "Firefox",
753 "/usr/bin/firefox %u",
754 None,
755 )]);
756 assert_eq!(names.get("firefox").map(String::as_str), Some("Firefox"));
757 }
758
759 #[test]
760 fn a_web_app_does_not_rename_its_browser() {
761 let names = names_from_entries(&[
762 entry(
763 "chrome-abc-Default",
764 "YouTube",
765 "/usr/bin/chromium --profile-directory=Default --app-id=abc",
766 None,
767 ),
768 entry("chromium", "Chromium", "/usr/bin/chromium %U", None),
769 ]);
770 assert_eq!(names.get("chromium").map(String::as_str), Some("Chromium"));
771 let names = names_from_entries(&[entry(
772 "chrome-abc-Default",
773 "YouTube",
774 "chromium --app=https://youtube.com",
775 None,
776 )]);
777 assert_eq!(names.get("chromium"), None);
778 }
779
780 #[test]
781 fn a_game_shortcut_does_not_rename_steam() {
782 let names = names_from_entries(&[
783 entry("Hades", "Hades", "steam steam://rungameid/1145360", None),
784 entry("steam", "Steam", "/usr/bin/steam %U", None),
785 ]);
786 assert_eq!(names.get("steam").map(String::as_str), Some("Steam"));
787 }
788
789 #[test]
790 fn a_terminal_command_does_not_rename_the_terminal() {
791 let names = names_from_entries(&[
792 entry("btop", "btop++", "kitty -e btop", None),
793 entry("kitty", "kitty", "kitty", None),
794 ]);
795 assert_eq!(names.get("kitty").map(String::as_str), Some("kitty"));
796 assert_eq!(names.get("btop"), None, "btop is not what runs");
797 }
798
799 #[test]
800 fn disagreeing_entries_need_a_matching_stem_or_class() {
801 let names = names_from_entries(&[
802 entry("code", "Visual Studio Code", "/usr/bin/code %F", None),
803 entry("my-project", "My Project", "/usr/bin/code /home/me/p", None),
804 ]);
805 assert_eq!(
806 names.get("code").map(String::as_str),
807 Some("Visual Studio Code")
808 );
809 let names = names_from_entries(&[
810 entry("a", "A", "tool", Some("Tool")),
811 entry("b", "B", "tool", None),
812 ]);
813 assert_eq!(names.get("tool").map(String::as_str), Some("A"));
814 let names =
815 names_from_entries(&[entry("a", "A", "tool", None), entry("b", "B", "tool", None)]);
816 assert_eq!(names.get("tool"), None, "still ambiguous");
817 }
818
819 fn resolver(files: &[(&str, &str)]) -> impl Fn(&str) -> Option<PathBuf> {
821 let files: HashMap<String, PathBuf> = files
822 .iter()
823 .map(|(p, f)| (p.to_string(), PathBuf::from(f)))
824 .collect();
825 move |program| files.get(program).cloned()
826 }
827
828 #[test]
829 fn an_app_is_indexed_by_the_directory_its_program_leads_to() {
830 let resolve = resolver(&[
831 (
832 "/usr/bin/google-chrome-stable",
833 "/opt/google/chrome/google-chrome",
834 ),
835 (
836 "/opt/google/chrome/google-chrome",
837 "/opt/google/chrome/google-chrome",
838 ),
839 ("firefox", "/usr/lib/firefox/firefox"),
840 ]);
841 let entries = [
842 entry(
843 "google-chrome",
844 "Google Chrome",
845 "/usr/bin/google-chrome-stable %U",
846 None,
847 ),
848 entry(
851 "chrome-abc-Default",
852 "YouTube",
853 "/opt/google/chrome/google-chrome --profile-directory=Default --app-id=abc",
854 None,
855 ),
856 entry("firefox", "Firefox", "firefox %u", None),
857 entry("ghost", "Ghost", "/usr/bin/ghost", None),
858 ];
859 let dirs = dirs_from_entries(&entries, &resolve, None);
860 assert_eq!(
861 dirs.get(Path::new("/opt/google/chrome"))
862 .map(String::as_str),
863 Some("Google Chrome")
864 );
865 assert_eq!(
866 dirs.get(Path::new("/usr/lib/firefox")).map(String::as_str),
867 Some("Firefox")
868 );
869 assert_eq!(
870 dirs.len(),
871 2,
872 "an unresolved program adds nothing: {dirs:?}"
873 );
874 }
875
876 #[test]
877 fn a_directory_claimed_by_different_apps_is_dropped() {
878 let resolve = resolver(&[
879 ("/usr/bin/writer", "/opt/suite/program/soffice"),
880 ("/usr/bin/calc", "/opt/suite/program/soffice"),
881 ("/usr/bin/tool", "/opt/tool/tool"),
882 ("/usr/local/bin/tool", "/opt/tool/tool"),
883 ]);
884 let dirs = dirs_from_entries(
885 &[
886 entry("writer", "Writer", "/usr/bin/writer", None),
887 entry("calc", "Calc", "/usr/bin/calc", None),
888 entry("tool", "Tool", "/usr/bin/tool", None),
891 entry("tool", "Tool", "/usr/local/bin/tool", None),
892 ],
893 &resolve,
894 None,
895 );
896 assert_eq!(dirs.get(Path::new("/opt/suite/program")), None);
897 assert_eq!(
898 dirs.get(Path::new("/opt/tool")).map(String::as_str),
899 Some("Tool")
900 );
901 }
902
903 #[test]
904 fn shared_bin_directories_and_launchers_are_not_indexed() {
905 let home = Path::new("/home/u");
906 let resolve = resolver(&[
907 ("gedit", "/usr/bin/gedit"),
908 ("/snap/bin/spotify", "/usr/bin/snap"),
909 ("mytool", "/home/u/.local/bin/mytool"),
910 ("python3", "/usr/bin/python3.12"),
911 ("/opt/app/run.sh", "/opt/app/run.sh"),
912 ]);
913 let dirs = dirs_from_entries(
914 &[
915 entry("gedit", "Text Editor", "gedit %U", None),
916 entry("spotify", "Spotify", "/snap/bin/spotify %U", None),
917 entry("mytool", "My Tool", "mytool", None),
918 entry("game", "Game", "python3 /opt/game/main.py", None),
919 entry("app", "App", "sh /opt/app/run.sh", None),
920 ],
921 &resolve,
922 Some(home),
923 );
924 assert!(dirs.is_empty(), "{dirs:?}");
925 }
926
927 #[test]
928 fn shared_directories() {
929 let home = Some(Path::new("/home/u"));
930 for dir in [
931 "/usr/bin",
932 "/bin",
933 "/usr/local/bin",
934 "/usr/games",
935 "/snap/bin",
936 "/var/lib/flatpak/exports/bin",
937 "/usr/lib",
938 "/usr/libexec",
939 "/usr/lib/x86_64-linux-gnu",
940 "/opt",
941 "/home/u",
942 "/home/u/bin",
943 "/home/u/.local/bin",
944 "/home/u/.local/bin/",
945 "/home/u/Applications",
946 "/home/u/go/bin",
947 "/home/u/.bun/bin",
948 "/home/u/.local/share/pnpm",
949 "/home/u/.dotnet/tools",
950 "/home/u/Downloads",
951 "/home/u/somefolder",
952 "/usr/local/go/bin",
953 "/usr/lib/jvm/java-17-openjdk/bin",
954 "/opt/tool/sbin",
955 ] {
956 assert!(is_shared_dir(Path::new(dir), home), "{dir}");
957 }
958 for dir in [
959 "/opt/google/chrome",
960 "/usr/lib/firefox",
961 "/usr/share/code",
962 "/home/u/apps/tool",
963 ] {
964 assert!(!is_shared_dir(Path::new(dir), home), "{dir}");
965 }
966 assert!(!is_shared_dir(Path::new("/home/u/somefolder"), None));
967 }
968
969 #[test]
970 fn runtimes_and_appimages_do_not_claim_a_directory() {
971 let home = Path::new("/home/u");
972 let resolve = resolver(&[
973 (
974 "/usr/lib/jvm/java-17-openjdk/bin/jconsole",
975 "/usr/lib/jvm/java-17-openjdk/bin/jconsole",
976 ),
977 ("electron30", "/usr/lib/electron30/electron"),
978 ("/usr/bin/foo", "/usr/lib/electron30/electron"),
979 ("/usr/bin/bar", "/usr/lib/node22/node"),
980 ("/opt/apps/Foo.AppImage", "/opt/apps/Foo.AppImage"),
981 ("/opt/apps/Baz.appimage", "/opt/apps/Baz.appimage"),
982 ]);
983 let dirs = dirs_from_entries(
984 &[
985 entry(
986 "jconsole",
987 "OpenJDK 17 Monitoring & Management Console",
988 "/usr/lib/jvm/java-17-openjdk/bin/jconsole",
989 None,
990 ),
991 entry("foo-asar", "Foo", "electron30 /usr/lib/foo/app.asar", None),
992 entry("foo", "Foo", "/usr/bin/foo", None),
993 entry("bar", "Bar", "/usr/bin/bar", None),
994 entry("foo-appimage", "Foo", "/opt/apps/Foo.AppImage", None),
995 entry("baz-appimage", "Baz", "/opt/apps/Baz.appimage", None),
996 ],
997 &resolve,
998 Some(home),
999 );
1000 assert!(dirs.is_empty(), "{dirs:?}");
1001 }
1002
1003 #[test]
1004 fn a_bare_program_is_found_on_absolute_path_entries_only() {
1005 let root = tempfile::tempdir().unwrap();
1006 let bin = root.path().join("bin");
1007 let app = root.path().join("app");
1008 fs::create_dir(&bin).unwrap();
1009 fs::create_dir(&app).unwrap();
1010 fs::write(app.join("tool"), "").unwrap();
1011 std::os::unix::fs::symlink(app.join("tool"), bin.join("tool")).unwrap();
1012 fs::create_dir(bin.join("adir")).unwrap();
1013 let found = find_on_path("tool", [PathBuf::from("rel"), bin.clone()].into_iter());
1014 assert_eq!(found, Some(fs::canonicalize(app.join("tool")).unwrap()));
1015 assert_eq!(find_on_path("adir", [bin.clone()].into_iter()), None);
1016 assert_eq!(find_on_path("none", [bin].into_iter()), None);
1017 let up = std::env::current_dir().unwrap().components().count() - 1;
1020 let rel = PathBuf::from("../".repeat(up)).join(app.strip_prefix("/").unwrap());
1021 assert!(rel.join("tool").is_file());
1022 assert_eq!(find_on_path("tool", [rel].into_iter()), None);
1023 }
1024
1025 #[test]
1026 fn interpreters_do_not_take_an_apps_name() {
1027 assert!(is_launcher("python3.14"));
1028 assert!(is_launcher("flatpak"));
1029 assert!(is_launcher("electron30"));
1030 assert!(is_launcher("node22"));
1031 assert!(is_launcher("java-17"));
1032 assert!(is_launcher("ruby3.2"));
1033 assert!(!is_launcher("firefox"));
1034 assert!(!is_launcher("chrome"));
1035 assert!(!is_launcher("javascript-tool"));
1036 }
1037}