pacsea/install/
command.rs

1//! Builds shell commands for installing packages via pacman or AUR helpers.
2
3use crate::state::{PackageItem, Source};
4
5use super::utils::shell_single_quote;
6
7/// What: Build the common AUR install body that prefers `paru` and falls back to `yay`.
8///
9/// Input:
10/// - `flags`: Flag string forwarded to the helper (e.g. `-S --needed`).
11/// - `n`: Space-separated package names to install.
12///
13/// Output:
14/// - Parenthesised shell snippet `(if ... fi)` without the trailing hold suffix.
15///
16/// Details:
17/// - Prefers `paru` if available, otherwise falls back to `yay`.
18/// - Shows error message if no AUR helper is found.
19#[must_use]
20pub fn aur_install_body(flags: &str, n: &str) -> String {
21    format!(
22        "(if command -v paru >/dev/null 2>&1; then \
23            paru {flags} {n}; \
24          elif command -v yay >/dev/null 2>&1; then \
25            yay {flags} {n}; \
26          else \
27            echo 'No AUR helper (paru/yay) found.'; \
28          fi)"
29    )
30}
31
32/// What: Build a shell command to install `item` and indicate whether `sudo` is used.
33///
34/// Input:
35/// - `item`: Package to install (official via pacman, AUR via helper).
36/// - `password`: Optional sudo password; when present, wires `sudo -S` with a pipe.
37/// - `dry_run`: When `true`, prints the command instead of executing.
38///
39/// Output:
40/// - Tuple `(command_string, uses_sudo)` with a shell-ready command and whether it requires sudo.
41///
42/// Details:
43/// - Uses `--needed` flag for new installs, omits it for reinstalls.
44/// - Adds a hold tail so spawned terminals remain open after completion.
45#[must_use]
46pub fn build_install_command(
47    item: &PackageItem,
48    password: Option<&str>,
49    dry_run: bool,
50) -> (String, bool) {
51    match &item.source {
52        Source::Official { .. } => {
53            let reinstall = crate::index::is_installed(&item.name);
54            let base_cmd = if reinstall {
55                format!("pacman -S --noconfirm {}", item.name)
56            } else {
57                format!("pacman -S --needed --noconfirm {}", item.name)
58            };
59            let hold_tail = "; echo; echo 'Finished.'; echo 'Press any key to close...'; read -rn1 -s _ || (echo; echo 'Press Ctrl+C to close'; sleep infinity)";
60            if dry_run {
61                let cmd = format!("sudo {base_cmd}{hold_tail}");
62                let quoted = shell_single_quote(&cmd);
63                let bash = format!("echo DRY RUN: {quoted}");
64                return (bash, true);
65            }
66            let pass = password.unwrap_or("");
67            if pass.is_empty() {
68                let bash = format!("sudo {base_cmd}{hold_tail}");
69                (bash, true)
70            } else {
71                let escaped = shell_single_quote(pass);
72                let pipe = format!("echo {escaped} | ");
73                let bash = format!("{pipe}sudo -S {base_cmd}{hold_tail}");
74                (bash, true)
75            }
76        }
77        Source::Aur => {
78            let hold_tail = "; echo; echo 'Press any key to close...'; read -rn1 -s _ || (echo; echo 'Press Ctrl+C to close'; sleep infinity)";
79            let reinstall = crate::index::is_installed(&item.name);
80            let flags = if reinstall {
81                "-S --noconfirm"
82            } else {
83                "-S --needed --noconfirm"
84            };
85            let aur_cmd = if dry_run {
86                let cmd = format!(
87                    "paru {flags} {n} || yay {flags} {n}{hold}",
88                    n = item.name,
89                    hold = hold_tail,
90                    flags = flags
91                );
92                let quoted = shell_single_quote(&cmd);
93                format!("echo DRY RUN: {quoted}")
94            } else {
95                format!(
96                    "{body}{hold}",
97                    body = aur_install_body(flags, &item.name),
98                    hold = hold_tail
99                )
100            };
101            (aur_cmd, false)
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    /// What: Check the pacman command builder for official packages handles sudo, password prompts, and dry-run mode.
112    ///
113    /// Inputs:
114    /// - Official package metadata.
115    /// - Optional password string.
116    /// - Dry-run flag toggled between `false` and `true`.
117    ///
118    /// Output:
119    /// - Returns commands containing the expected pacman flags, optional `sudo -S` echo, and dry-run prefix.
120    ///
121    /// Details:
122    /// - Ensures the hold-tail message persists and the helper flags remain in sync with UI behaviour.
123    fn install_build_install_command_official_variants() {
124        let pkg = PackageItem {
125            name: "ripgrep".into(),
126            version: "14".into(),
127            description: String::new(),
128            source: Source::Official {
129                repo: "extra".into(),
130                arch: "x86_64".into(),
131            },
132            popularity: None,
133            out_of_date: None,
134            orphaned: false,
135        };
136
137        let (cmd1, uses_sudo1) = build_install_command(&pkg, None, false);
138        assert!(uses_sudo1);
139        assert!(cmd1.contains("sudo pacman -S --needed --noconfirm ripgrep"));
140        assert!(cmd1.contains("Press any key to close"));
141
142        let (cmd2, uses_sudo2) = build_install_command(&pkg, Some("pa's"), false);
143        assert!(uses_sudo2);
144        assert!(cmd2.contains("echo "));
145        assert!(cmd2.contains("sudo -S pacman -S --needed --noconfirm ripgrep"));
146
147        let (cmd3, uses_sudo3) = build_install_command(&pkg, None, true);
148        assert!(uses_sudo3);
149        // Dry-run commands are now properly quoted to avoid syntax errors
150        assert!(cmd3.starts_with("echo DRY RUN: '"));
151        assert!(cmd3.contains("sudo pacman -S --needed --noconfirm ripgrep"));
152    }
153
154    #[test]
155    /// What: Verify AUR command construction selects the correct helper and respects dry-run output.
156    ///
157    /// Inputs:
158    /// - AUR package metadata.
159    /// - Dry-run flag toggled between `false` and `true`.
160    ///
161    /// Output:
162    /// - Produces scripts that prefer `paru`, fall back to `yay`, and emit a dry-run echo when requested.
163    ///
164    /// Details:
165    /// - Asserts the crafted shell script still includes the hold-tail prompt and missing-helper warning.
166    fn install_build_install_command_aur_variants() {
167        let pkg = PackageItem {
168            name: "yay-bin".into(),
169            version: "1".into(),
170            description: String::new(),
171            source: Source::Aur,
172            popularity: None,
173            out_of_date: None,
174            orphaned: false,
175        };
176
177        let (cmd1, uses_sudo1) = build_install_command(&pkg, None, false);
178        assert!(!uses_sudo1);
179        assert!(cmd1.contains("command -v paru"));
180        assert!(cmd1.contains("paru -S --needed --noconfirm yay-bin"));
181        assert!(cmd1.contains("elif command -v yay"));
182        assert!(cmd1.contains("No AUR helper"));
183        assert!(cmd1.contains("Press any key to close"));
184
185        let (cmd2, uses_sudo2) = build_install_command(&pkg, None, true);
186        assert!(!uses_sudo2);
187        // Dry-run commands are now properly quoted to avoid syntax errors
188        assert!(cmd2.starts_with("echo DRY RUN: '"));
189        assert!(cmd2.contains("paru -S --needed --noconfirm yay-bin"));
190    }
191}