hyprshell_core_lib/util/
exists.rs

1use std::env;
2use std::env::split_paths;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6#[cfg(unix)]
7use std::os::unix::fs::PermissionsExt;
8
9// Common fallback directories.
10const COMMON_DIRS: &[&str] = &[
11    "/usr/bin",
12    "/usr/local/bin",
13    "/bin",
14    "/sbin",
15    "/usr/sbin",
16    "/usr/local/sbin",
17    "/snap/bin",
18];
19// NixOS-specific locations.
20const NIX_DIRS: &[&str] = &[
21    "/run/current-system/sw/bin",
22    "/nix/var/nix/profiles/default/bin",
23];
24
25/// Try to find an executable named `name`.
26///
27/// - If `name` contains a path separator, that path is checked directly.
28/// - Otherwise the function searches:
29///   - directories from `PATH`
30///   - some common system directories (`/usr/bin`, `/usr/local/bin`, `/bin`, ...)
31///   - NixOS-specific locations (`/run/current-system/sw/bin`, `$HOME/.nix-profile/bin`, `/nix/var/nix/profiles/default/bin`)
32///
33/// Returns `Some(PathBuf)` of the first found executable, or `None`.
34#[must_use]
35pub fn find_command(name: &str) -> Option<PathBuf> {
36    if name.is_empty() {
37        return None;
38    }
39
40    let path = Path::new(name);
41
42    // If name contains a path separator, check that directly.
43    if path.components().count() > 1 {
44        return if is_executable(path) {
45            Some(path.to_path_buf())
46        } else {
47            None
48        };
49    }
50
51    // Collect candidate directories from PATH.
52    let env_path = env::var_os("PATH").unwrap_or_default();
53    let mut candidates: Vec<_> = split_paths(&env_path).collect();
54
55    for d in COMMON_DIRS {
56        candidates.push(PathBuf::from(d));
57    }
58
59    for d in NIX_DIRS {
60        candidates.push(PathBuf::from(d));
61    }
62
63    // User Nix profile if HOME is present.
64    if let Some(home) = env::var_os("HOME") {
65        candidates.push(PathBuf::from(home).join(".nix-profile").join("bin"));
66    }
67
68    // Check each candidate directory for the executable.
69    for dir in candidates {
70        if dir.as_os_str().is_empty() {
71            continue;
72        }
73        let candidate = dir.clone().join(name);
74        if is_executable(&candidate) {
75            return Some(candidate);
76        }
77    }
78
79    None
80}
81
82/// Convenience wrapper that returns true if the command exists somewhere.
83#[must_use]
84pub fn command_exists(name: &str) -> bool {
85    find_command(name).is_some()
86}
87
88fn is_executable(path: &Path) -> bool {
89    if !path.exists() {
90        return false;
91    }
92    if !path.is_file() {
93        return false;
94    }
95
96    #[cfg(unix)]
97    {
98        if let Ok(meta) = fs::metadata(path) {
99            let mode = meta.permissions().mode();
100            // Check any of the execute bits (owner/group/other).
101            return mode & 0o111 != 0;
102        }
103        false
104    }
105
106    #[cfg(not(unix))]
107    {
108        // On non-unix platforms, fallback to "exists and is file".
109        true
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn find_shell_exists() {
119        // common shell should exist on CI / dev machines; skip strict assertion
120        let candidates = ["sh", "bash", "zsh"];
121        let found = candidates.iter().any(|c| command_exists(c));
122        assert!(found, "expected at least one common shell to exist");
123    }
124
125    #[test]
126    fn nonexistent_command_does_not_exist() {
127        assert!(!command_exists(
128            "this-command-should-never-exist-12345-amogus"
129        ));
130    }
131}