Skip to main content

rlmctl_core/
desktop.rs

1use common::Result;
2use std::collections::HashMap;
3use std::fs;
4use std::os::unix::fs::PermissionsExt;
5use std::path::{Path, PathBuf};
6
7/// Desktop application entry
8#[derive(Clone)]
9pub struct DesktopApp {
10    pub name: String,
11    pub exec: String,
12    pub is_cli: bool,
13}
14
15/// Directories searched for `.desktop` files: the system ones, then the
16/// user's own.
17fn 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
30/// Every shown application entry in [`desktop_dirs`].
31fn 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
48/// List installed applications from .desktop files
49pub 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/// Installed application names, from the desktop entries.
66#[derive(Debug, Default, Clone, PartialEq, Eq)]
67pub struct DesktopNames {
68    /// By the basename of the program an entry's `Exec` runs (`firefox` to
69    /// `Firefox`). See [`names_from_entries`].
70    pub programs: HashMap<String, String>,
71    /// By the directory an app is installed in (`/opt/google/chrome` to
72    /// `Google Chrome`), for a process whose program no entry runs by name.
73    /// See [`dirs_from_entries`].
74    pub dirs: HashMap<PathBuf, String>,
75}
76
77/// The installed applications' [`DesktopNames`].
78pub 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
86/// Program basename to app name, from `entries`. Entries that run an
87/// interpreter or launcher, or that pass arguments launching something else
88/// (a web app, a game shortcut, a terminal command), are skipped: their
89/// `Name` is not the program's. When the remaining entries for one program
90/// disagree on the name, only those whose file stem or `StartupWMClass`
91/// matches the program count; if they still disagree, the program gets no
92/// name here.
93fn 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
121/// App install directory to app name, from `entries`: the directory of the
122/// file each entry's program really is, as `resolve` finds it (following
123/// symlinks, so `/usr/bin/google-chrome-stable` gives `/opt/google/chrome`).
124/// The entries [`names_from_entries`] skips are skipped here too, and so
125/// are those whose file is an interpreter or runtime (its directory holds
126/// whatever app it runs) or an AppImage (AppImages share a folder). A
127/// directory many apps share (see [`is_shared_dir`]) is left out, and so is
128/// one whose entries disagree on the name.
129fn 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
156/// The file an Exec program really is, symlinks followed: a path as it
157/// stands, a bare name as found on `PATH`.
158fn 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
165/// The first file named `program` in `dirs`, symlinks followed. Relative
166/// directories are skipped: they depend on where this process happens to
167/// run.
168fn 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
174/// Whether `dir` holds the programs of many apps, so living there says
175/// nothing about which app a program belongs to: any `bin` or `sbin`, the
176/// library directories, `home` and the folders right in it, and the like.
177fn 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    // Multiarch library directories such as /usr/lib/x86_64-linux-gnu.
232    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
242/// The program an entry runs, as its `Exec` names it (past an `env`
243/// wrapper). `None` for an entry that runs an interpreter or launcher, or
244/// passes arguments launching something else (a web app, a game shortcut,
245/// a terminal command): its `Name` is not the program's.
246fn 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
255/// The one name all `entries` share, `None` if there are none or several.
256fn 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
262/// Whether an entry is the program's own: its file stem (or the last part
263/// of a reverse-DNS stem such as `org.mozilla.firefox`) or its
264/// `StartupWMClass` is the program name, ignoring case.
265fn 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
275/// An Exec argument that makes the program open some other app or a URL:
276/// a browser web app, a Steam or Lutris game, or a terminal command.
277fn 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
286/// Interpreters and launchers: an entry that runs one of these names some
287/// other app, so its `Name` must not label every process of that program.
288/// Versioned runtimes (`electron30`, `python3.12`, `node22`, `java-17`)
289/// count too.
290pub(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
325/// The basename of the program a raw desktop file `Exec` value runs, looking
326/// past an `env VAR=value` wrapper. `None` if there is none.
327pub fn exec_program(value: &str) -> Option<String> {
328    program_of(&exec_args(value)?)
329}
330
331/// A raw desktop file `Exec` value split into arguments, field codes
332/// removed. `None` when a quote is left open.
333fn 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
342/// The basename of the program `args` run, past an `env` wrapper.
343fn program_of(args: &[String]) -> Option<String> {
344    let base = basename(program_arg(args)?);
345    (!base.is_empty()).then_some(base)
346}
347
348/// The last part of a path, the whole of it when it has no `/`.
349fn basename(program: &str) -> String {
350    program.rsplit('/').next().unwrap_or(program).to_string()
351}
352
353/// The program `args` run as they name it, past an `env` wrapper.
354fn 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
373/// A shown application entry: its `Name`, raw `Exec` value, file name
374/// without `.desktop`, and `StartupWMClass`.
375struct Entry {
376    name: String,
377    exec: String,
378    stem: String,
379    wm_class: Option<String>,
380}
381
382/// Read the `[Desktop Entry]` group of one file. `None` for a hidden entry,
383/// a non-application, or one without a usable `Name` and `Exec`.
384fn 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
436/// Turn a desktop file `Exec` value into a shell-quoted command line: field
437/// codes (%u, %F, ...) removed and each argument quoted when it holds spaces
438/// or shell characters, so a shell-style split gives back the same
439/// arguments. An `env VAR=value app` wrapper is kept whole, with `env` as
440/// the program, so the app still gets its variables. `None` if nothing is
441/// left, an `env` wrapper names no program, or a quote is not closed.
442pub 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
459/// Whether the arguments after `env` name a program to run, rather than only
460/// settings (`VAR=value`, `-u NAME`, `-i`, ...), in which case `env` would
461/// just print the environment.
462fn 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            // These take the next argument as their value.
467            "-u" | "--unset" | "-C" | "--chdir" => {
468                args.next();
469            }
470            // The string holds the command line itself.
471            "-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
482/// Undo the escapes every desktop file string value may use: \s, \n, \t,
483/// \r and \\.
484fn 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
508/// Split an Exec value into arguments. Double quotes group an argument;
509/// inside them a backslash escapes `"`, `` ` ``, `$` and `\`. `None` when
510/// a quote is left open.
511fn 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
552/// Remove field codes from one argument: an argument that is only a code
553/// (such as %U) is dropped, `%%` becomes `%`, and codes inside an argument
554/// are removed.
555fn 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
573/// Quote `arg` for a POSIX shell-style split, leaving plain words as they
574/// are.
575fn 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
587/// Search PATH for executables matching a query
588pub 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                    // Check if executable
607                    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                                // Quoted, since a file name may hold spaces or
612                                // shell characters and the command is split
613                                // like a shell line.
614                                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        // Inside quotes a backslash escapes ", `, $ and \. The file itself
661        // writes a backslash as \\, so \\\\ in the file is one backslash.
662        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        // \s is unescaped before splitting, so it separates words.
671        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    /// A fake filesystem: where each Exec program really is.
820    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            // A web app runs the same program; it must not claim the
849            // directory (nor make it ambiguous).
850            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                // The same app installed twice (a system and a user entry)
889                // agrees on the name, so it stays.
890                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        // A relative entry is never searched, even when it leads to the file
1018        // from the current directory.
1019        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}