pacsea/install/
utils.rs

1#[cfg(target_os = "windows")]
2/// What: Determine whether a command is available on the Windows `PATH`.
3///
4/// Input:
5/// - `cmd`: Executable name to probe.
6///
7/// Output:
8/// - `true` when the command resolves via the `which` crate; otherwise `false`.
9///
10/// Details:
11/// - Leverages `which::which`, inheriting its support for PATHEXT resolution.
12#[must_use]
13pub fn command_on_path(cmd: &str) -> bool {
14    which::which(cmd).is_ok()
15}
16
17#[cfg(target_os = "windows")]
18/// What: Check if `PowerShell` is available on Windows.
19///
20/// Output:
21/// - `true` when `PowerShell` can be found on PATH; otherwise `false`.
22///
23/// Details:
24/// - Checks for `powershell.exe` or `pwsh.exe` (`PowerShell` Core) on the system.
25pub fn is_powershell_available() -> bool {
26    command_on_path("powershell.exe") || command_on_path("pwsh.exe")
27}
28
29#[cfg(not(target_os = "windows"))]
30/// What: Determine whether a command is available on the Unix `PATH`.
31///
32/// Input:
33/// - `cmd`: Program name or explicit path to inspect.
34///
35/// Output:
36/// - `true` when an executable file is found and marked executable.
37///
38/// Details:
39/// - Accepts explicit paths (containing path separators) and honours Unix permission bits.
40/// - Falls back to scanning `PATH`, and on Windows builds respects `PATHEXT` as well.
41#[must_use]
42pub fn command_on_path(cmd: &str) -> bool {
43    use std::path::Path;
44
45    fn is_exec(p: &std::path::Path) -> bool {
46        if !p.is_file() {
47            return false;
48        }
49        #[cfg(unix)]
50        {
51            use std::os::unix::fs::PermissionsExt;
52            if let Ok(meta) = std::fs::metadata(p) {
53                return meta.permissions().mode() & 0o111 != 0;
54            }
55            false
56        }
57        #[cfg(not(unix))]
58        {
59            true
60        }
61    }
62
63    if cmd.contains(std::path::MAIN_SEPARATOR) {
64        return is_exec(Path::new(cmd));
65    }
66
67    if let Some(paths) = std::env::var_os("PATH") {
68        for dir in std::env::split_paths(&paths) {
69            let candidate = dir.join(cmd);
70            if is_exec(&candidate) {
71                return true;
72            }
73            #[cfg(windows)]
74            {
75                if let Some(pathext) = std::env::var_os("PATHEXT") {
76                    for ext in pathext.to_string_lossy().split(';') {
77                        let candidate = dir.join(format!("{cmd}{ext}"));
78                        if candidate.is_file() {
79                            return true;
80                        }
81                    }
82                }
83            }
84        }
85    }
86    false
87}
88
89#[cfg(not(target_os = "windows"))]
90/// What: Locate the first available terminal executable from a preference list.
91///
92/// Input:
93/// - `terms`: Tuples of `(binary, args, needs_xfce_command)` ordered by preference.
94///
95/// Output:
96/// - `Some(index)` pointing into `terms` when a binary is found; otherwise `None`.
97///
98/// Details:
99/// - Iterates directories in `PATH`, favouring the earliest match respecting executable bits.
100pub fn choose_terminal_index_prefer_path(terms: &[(&str, &[&str], bool)]) -> Option<usize> {
101    use std::os::unix::fs::PermissionsExt;
102    if let Some(paths) = std::env::var_os("PATH") {
103        for dir in std::env::split_paths(&paths) {
104            for (i, (name, _args, _hold)) in terms.iter().enumerate() {
105                let candidate = dir.join(name);
106                if candidate.is_file()
107                    && let Ok(meta) = std::fs::metadata(&candidate)
108                    && meta.permissions().mode() & 0o111 != 0
109                {
110                    return Some(i);
111                }
112            }
113        }
114    }
115    None
116}
117
118/// What: Safely single-quote an arbitrary string for POSIX shells.
119///
120/// Input:
121/// - `s`: Text to quote.
122///
123/// Output:
124/// - New string wrapped in single quotes, escaping embedded quotes via the `'
125///   '"'"'` sequence.
126///
127/// Details:
128/// - Returns `''` for empty input so the shell treats it as an empty argument.
129#[must_use]
130pub fn shell_single_quote(s: &str) -> String {
131    if s.is_empty() {
132        return "''".to_string();
133    }
134    let mut out = String::with_capacity(s.len() + 2);
135    out.push('\'');
136    for ch in s.chars() {
137        if ch == '\'' {
138            out.push_str("'\"'\"'");
139        } else {
140            out.push(ch);
141        }
142    }
143    out.push('\'');
144    out
145}
146
147#[cfg(not(target_os = "windows"))]
148/// Fallback message when no terminal editor is found. Must not contain single quotes (used inside `echo '...'` in shell).
149/// Reusable for i18n or logging if needed.
150const EDITOR_FALLBACK_MESSAGE: &str = "No terminal editor found (nvim/vim/emacsclient/emacs/hx/helix/nano). Set VISUAL or EDITOR to use your preferred editor.";
151
152#[cfg(not(target_os = "windows"))]
153/// What: Build a shell command string that opens a config file in the user's preferred terminal editor.
154///
155/// Inputs:
156/// - `path`: Path to the config file to open.
157///
158/// Output:
159/// - A single shell expression that tries, in order: `$VISUAL`, `$EDITOR`, then the built-in
160///   fallback chain (nvim, vim, hx, helix, emacsclient, emacs, nano), and finally a fallback
161///   message with `read -rn1 -s _`.
162///
163/// Details:
164/// - The script expects `VISUAL`/`EDITOR` to be runnable commands that accept a file path.
165/// - The path is passed through `shell_single_quote` so paths with spaces or single quotes are safe.
166/// - Order: VISUAL then EDITOR then nvim → vim → hx → helix → emacsclient -t → emacs -nw → nano.
167#[must_use]
168pub fn editor_open_config_command(path: &std::path::Path) -> String {
169    let path_str = path.display().to_string();
170    let path_quoted = shell_single_quote(&path_str);
171    // path_quoted is already single-quoted, so the full argument to echo is one safe string.
172    format!(
173        "( [ -n \"${{VISUAL}}\" ] && command -v \"${{VISUAL%% *}}\" >/dev/null 2>&1 && eval \"${{VISUAL}}\" {path_quoted} ) || \
174         ( [ -n \"${{EDITOR}}\" ] && command -v \"${{EDITOR%% *}}\" >/dev/null 2>&1 && eval \"${{EDITOR}}\" {path_quoted} ) || \
175         ((command -v nvim >/dev/null 2>&1 || sudo pacman -Qi neovim >/dev/null 2>&1) && nvim {path_quoted}) || \
176         ((command -v vim >/dev/null 2>&1 || sudo pacman -Qi vim >/dev/null 2>&1) && vim {path_quoted}) || \
177         ((command -v hx >/dev/null 2>&1 || sudo pacman -Qi helix >/dev/null 2>&1) && hx {path_quoted}) || \
178         ((command -v helix >/dev/null 2>&1 || sudo pacman -Qi helix >/dev/null 2>&1) && helix {path_quoted}) || \
179         ((command -v emacsclient >/dev/null 2>&1 || sudo pacman -Qi emacs >/dev/null 2>&1) && emacsclient -t {path_quoted}) || \
180         ((command -v emacs >/dev/null 2>&1 || sudo pacman -Qi emacs >/dev/null 2>&1) && emacs -nw {path_quoted}) || \
181         ((command -v nano >/dev/null 2>&1 || sudo pacman -Qi nano >/dev/null 2>&1) && nano {path_quoted}) || \
182         (echo '{EDITOR_FALLBACK_MESSAGE}'; echo 'File: {path_quoted}'; read -rn1 -s _ || true)"
183    )
184}
185
186#[cfg(all(test, not(target_os = "windows")))]
187mod tests {
188    #[test]
189    /// What: Validate that `command_on_path` recognises executables present on the customised `PATH`.
190    ///
191    /// Inputs:
192    /// - Temporary directory containing a shim `mycmd` script made executable.
193    /// - Environment `PATH` overridden to reference only the temp directory.
194    ///
195    /// Output:
196    /// - Returns `true` for `mycmd` and `false` for a missing binary, confirming detection logic.
197    ///
198    /// Details:
199    /// - Restores the original `PATH` and cleans up the temporary directory after assertions.
200    fn utils_command_on_path_detects_executable() {
201        use std::fs;
202        use std::os::unix::fs::PermissionsExt;
203        use std::path::PathBuf;
204
205        let mut dir: PathBuf = std::env::temp_dir();
206        dir.push(format!(
207            "pacsea_test_utils_path_{}_{}",
208            std::process::id(),
209            std::time::SystemTime::now()
210                .duration_since(std::time::UNIX_EPOCH)
211                .expect("System time is before UNIX epoch")
212                .as_nanos()
213        ));
214        let _ = fs::create_dir_all(&dir);
215        let mut cmd_path = dir.clone();
216        cmd_path.push("mycmd");
217        fs::write(&cmd_path, b"#!/bin/sh\nexit 0\n").expect("Failed to write test command script");
218        let mut perms = fs::metadata(&cmd_path)
219            .expect("Failed to read test command script metadata")
220            .permissions();
221        perms.set_mode(0o755);
222        fs::set_permissions(&cmd_path, perms)
223            .expect("Failed to set test command script permissions");
224
225        let orig_path = std::env::var_os("PATH");
226        unsafe { std::env::set_var("PATH", dir.display().to_string()) };
227        assert!(super::command_on_path("mycmd"));
228        assert!(!super::command_on_path("notexist"));
229        unsafe {
230            if let Some(v) = orig_path {
231                std::env::set_var("PATH", v);
232            } else {
233                std::env::remove_var("PATH");
234            }
235        }
236        let _ = fs::remove_dir_all(&dir);
237    }
238
239    #[test]
240    /// What: Ensure `choose_terminal_index_prefer_path` honours the preference ordering when multiple terminals exist.
241    ///
242    /// Inputs:
243    /// - Temporary directory with an executable `kitty` shim placed on `PATH`.
244    /// - Preference list where `gnome-terminal` precedes `kitty` but is absent.
245    ///
246    /// Output:
247    /// - Function returns index `1`, selecting `kitty`, the first available terminal in the list.
248    ///
249    /// Details:
250    /// - Saves and restores the `PATH` environment variable while ensuring the temp directory is removed.
251    fn utils_choose_terminal_index_prefers_first_present_in_terms_order() {
252        use std::fs;
253        use std::os::unix::fs::PermissionsExt;
254        use std::path::PathBuf;
255
256        let mut dir: PathBuf = std::env::temp_dir();
257        dir.push(format!(
258            "pacsea_test_utils_terms_{}_{}",
259            std::process::id(),
260            std::time::SystemTime::now()
261                .duration_since(std::time::UNIX_EPOCH)
262                .expect("System time is before UNIX epoch")
263                .as_nanos()
264        ));
265        let _ = fs::create_dir_all(&dir);
266        let mut kitty = dir.clone();
267        kitty.push("kitty");
268        fs::write(&kitty, b"#!/bin/sh\nexit 0\n").expect("Failed to write test kitty script");
269        let mut perms = fs::metadata(&kitty)
270            .expect("Failed to read test kitty script metadata")
271            .permissions();
272        perms.set_mode(0o755);
273        fs::set_permissions(&kitty, perms).expect("Failed to set test kitty script permissions");
274
275        let terms: &[(&str, &[&str], bool)] =
276            &[("gnome-terminal", &[], false), ("kitty", &[], false)];
277        let orig_path = std::env::var_os("PATH");
278        unsafe { std::env::set_var("PATH", dir.display().to_string()) };
279        let idx = super::choose_terminal_index_prefer_path(terms).expect("index");
280        assert_eq!(idx, 1);
281        unsafe {
282            if let Some(v) = orig_path {
283                std::env::set_var("PATH", v);
284            } else {
285                std::env::remove_var("PATH");
286            }
287        }
288        let _ = fs::remove_dir_all(&dir);
289    }
290
291    #[test]
292    /// What: Check that `shell_single_quote` escapes edge cases safely.
293    ///
294    /// Inputs:
295    /// - Three sample strings: empty, plain ASCII, and text containing a single quote.
296    ///
297    /// Output:
298    /// - Returns properly quoted strings, using `''` for empty and the standard POSIX escape for embedded quotes.
299    ///
300    /// Details:
301    /// - Covers representative cases without filesystem interaction to guard future regressions.
302    fn utils_shell_single_quote_handles_edges() {
303        assert_eq!(super::shell_single_quote(""), "''");
304        assert_eq!(super::shell_single_quote("abc"), "'abc'");
305        assert_eq!(super::shell_single_quote("a'b"), "'a'\"'\"'b'");
306    }
307
308    #[test]
309    /// What: Assert that `editor_open_config_command` builds a command with VISUAL then EDITOR then fallbacks.
310    ///
311    /// Inputs:
312    /// - A dummy path (e.g. `/tmp/settings.conf`).
313    ///
314    /// Output:
315    /// - The returned string contains VISUAL branch before EDITOR branch before nvim fallback.
316    ///
317    /// Details:
318    /// - Shell-only implementation; order is fixed in the string regardless of env.
319    fn utils_editor_open_config_command_order_visual_then_editor_then_fallbacks() {
320        use std::path::Path;
321        let path = Path::new("/tmp/settings.conf");
322        let cmd = super::editor_open_config_command(path);
323        let idx_visual = cmd.find("VISUAL").expect("command must mention VISUAL");
324        let idx_editor = cmd.find("EDITOR").expect("command must mention EDITOR");
325        let idx_nvim = cmd
326            .find("nvim")
327            .expect("command must mention nvim fallback");
328        assert!(idx_visual < idx_editor, "VISUAL must appear before EDITOR");
329        assert!(
330            idx_editor < idx_nvim,
331            "EDITOR must appear before nvim fallback"
332        );
333    }
334
335    #[test]
336    /// What: Assert that `editor_open_config_command` includes the full fallback chain and final message.
337    ///
338    /// Inputs:
339    /// - A dummy path.
340    ///
341    /// Output:
342    /// - The returned string contains nvim, vim, hx, helix, emacsclient, emacs, nano and "No terminal editor found".
343    ///
344    /// Details:
345    /// - Validates the built-in fallback list and fallback message without executing shell.
346    fn utils_editor_open_config_command_contains_fallback_chain_and_message() {
347        use std::path::Path;
348        let path = Path::new("/tmp/theme.conf");
349        let cmd = super::editor_open_config_command(path);
350        assert!(cmd.contains("nvim"), "fallback chain must include nvim");
351        assert!(cmd.contains("vim"), "fallback chain must include vim");
352        assert!(cmd.contains("hx"), "fallback chain must include hx");
353        assert!(cmd.contains("helix"), "fallback chain must include helix");
354        assert!(
355            cmd.contains("emacsclient"),
356            "fallback chain must include emacsclient"
357        );
358        assert!(cmd.contains("emacs"), "fallback chain must include emacs");
359        assert!(cmd.contains("nano"), "fallback chain must include nano");
360        assert!(
361            cmd.contains("No terminal editor found"),
362            "command must include fallback message"
363        );
364    }
365
366    #[test]
367    /// What: Assert that the path in `editor_open_config_command` is shell-single-quoted.
368    ///
369    /// Inputs:
370    /// - A path containing a single quote (e.g. `/tmp/foo'bar.conf`).
371    ///
372    /// Output:
373    /// - The returned string contains the safely quoted path (single-quote escape sequence), not raw path.
374    ///
375    /// Details:
376    /// - Paths with single quotes must be quoted via `shell_single_quote` so the shell sees one argument.
377    fn utils_editor_open_config_command_path_is_shell_single_quoted() {
378        use std::path::Path;
379        let path_with_quote = Path::new("/tmp/foo'bar.conf");
380        let path_str = path_with_quote.display().to_string();
381        let path_quoted = super::shell_single_quote(&path_str);
382        let cmd = super::editor_open_config_command(path_with_quote);
383        assert!(
384            cmd.contains(&path_quoted),
385            "command must contain shell-single-quoted path, got quoted: {path_quoted:?}"
386        );
387        // Raw unquoted path with single quote would break shell; must not appear as '/tmp/foo'bar.conf'
388        assert!(
389            !cmd.contains("/tmp/foo'bar.conf"),
390            "command must not contain raw path with unescaped single quote"
391        );
392    }
393}