Skip to main content

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, validate_package_names};
6
7/// What: Flag sequence for a non-interactive `paru`/`yay` `-S` install of **AUR-only** targets.
8///
9/// Inputs:
10/// - `reinstall`: When `true`, omit `--needed` (reinstall path).
11///
12/// Output:
13/// - Static flag string including `-S` and `--aur` (e.g. `-S --aur --needed --noconfirm`).
14///
15/// Details:
16/// - Same string is passed to both helpers via [`aur_install_body`].
17/// - `--aur` ensures helpers do not prefer a sync database (e.g. Chaotic-AUR) when the same name exists on the AUR.
18#[must_use]
19pub const fn aur_install_helper_flags(reinstall: bool) -> &'static str {
20    if reinstall {
21        "-S --aur --noconfirm"
22    } else {
23        "-S --aur --needed --noconfirm"
24    }
25}
26
27/// What: Build the common AUR install body that prefers `paru` and falls back to `yay`.
28///
29/// Input:
30/// - `flags`: Full flag string forwarded to the helper (use [`aur_install_helper_flags`] for installs).
31/// - `n`: Space-separated **AUR** package names only (must not include official/repo targets).
32///
33/// Output:
34/// - Parenthesised shell snippet `(if ... fi)` without the trailing hold suffix.
35///
36/// Details:
37/// - Prefers `paru` if available, otherwise falls back to `yay`.
38/// - Shows error message if no AUR helper is found.
39#[must_use]
40pub fn aur_install_body(flags: &str, n: &str) -> String {
41    format!(
42        "(if command -v paru >/dev/null 2>&1; then \
43            paru {flags} {n}; \
44          elif command -v yay >/dev/null 2>&1; then \
45            yay {flags} {n}; \
46          else \
47            echo 'No AUR helper (paru/yay) found.'; \
48          fi)"
49    )
50}
51
52/// What: Build a shell command to install `item` and indicate whether `sudo` is used.
53///
54/// Input:
55/// - `item`: Package to install (official via pacman, AUR via helper).
56/// - `password`: Optional sudo password; when present, wires `sudo -S` with a pipe.
57/// - `dry_run`: When `true`, prints the command instead of executing.
58///
59/// Output:
60/// - `Ok((command_string, uses_sudo))` with a shell-ready command and whether it requires sudo.
61///
62/// # Errors
63///
64/// Returns `Err` when the configured privilege tool cannot be resolved for official packages.
65///
66/// Details:
67/// - Uses `--needed` flag for new installs, omits it for reinstalls.
68/// - Adds a hold tail so spawned terminals remain open after completion.
69pub fn build_install_command(
70    item: &PackageItem,
71    password: Option<&str>,
72    dry_run: bool,
73) -> Result<(String, bool), String> {
74    validate_package_names(
75        std::slice::from_ref(&item.name),
76        "install command construction",
77    )?;
78    let quoted_name = shell_single_quote(&item.name);
79    match &item.source {
80        Source::Official { .. } => {
81            let tool = crate::logic::privilege::active_tool()?;
82            let reinstall = crate::index::is_installed(&item.name);
83            let base_cmd = if reinstall {
84                format!("pacman -S --noconfirm {quoted_name}")
85            } else {
86                format!("pacman -S --needed --noconfirm {quoted_name}")
87            };
88            let hold_tail = "; echo; echo 'Finished.'; echo 'Press any key to close...'; read -rn1 -s _ || (echo; echo 'Press Ctrl+C to close'; sleep infinity)";
89            if dry_run {
90                let cmd = format!(
91                    "{}{hold_tail}",
92                    crate::logic::privilege::build_privilege_command(tool, &base_cmd)
93                );
94                let quoted = shell_single_quote(&cmd);
95                let bash = format!("echo DRY RUN: {quoted}");
96                return Ok((bash, true));
97            }
98            let pass = password.unwrap_or("");
99            if pass.is_empty() {
100                let bash = format!(
101                    "{}{hold_tail}",
102                    crate::logic::privilege::build_privilege_command(tool, &base_cmd)
103                );
104                Ok((bash, true))
105            } else {
106                let piped = crate::logic::privilege::build_password_pipe(tool, pass, &base_cmd);
107                let priv_cmd = piped.unwrap_or_else(|| {
108                    crate::logic::privilege::build_privilege_command(tool, &base_cmd)
109                });
110                let bash = format!("{priv_cmd}{hold_tail}");
111                Ok((bash, true))
112            }
113        }
114        Source::Aur => {
115            let hold_tail = "; echo; echo 'Press any key to close...'; read -rn1 -s _ || (echo; echo 'Press Ctrl+C to close'; sleep infinity)";
116            let reinstall = crate::index::is_installed(&item.name);
117            let flags = aur_install_helper_flags(reinstall);
118            let aur_cmd = if dry_run {
119                let cmd =
120                    format!("paru {flags} {quoted_name} || yay {flags} {quoted_name}{hold_tail}");
121                let quoted = shell_single_quote(&cmd);
122                format!("echo DRY RUN: {quoted}")
123            } else {
124                format!(
125                    "{body}{hold}",
126                    body = aur_install_body(flags, &quoted_name),
127                    hold = hold_tail
128                )
129            };
130            Ok((aur_cmd, false))
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    /// What: Check the pacman command builder for official packages handles privilege tool,
141    /// password prompts, and dry-run mode.
142    ///
143    /// Inputs:
144    /// - Official package metadata.
145    /// - Optional password string.
146    /// - Dry-run flag toggled between `false` and `true`.
147    ///
148    /// Output:
149    /// - Returns commands containing the expected pacman flags, optional piped password,
150    ///   and dry-run prefix.
151    ///
152    /// Details:
153    /// - Ensures the hold-tail message persists and the helper flags remain in sync with UI behaviour.
154    /// - Uses privilege abstraction so output adapts to active tool (sudo or doas).
155    fn install_build_install_command_official_variants() {
156        let tool = crate::logic::privilege::active_tool().expect("privilege tool");
157        let bin = tool.binary_name();
158
159        let pkg = PackageItem {
160            name: "ripgrep".into(),
161            version: "14".into(),
162            description: String::new(),
163            source: Source::Official {
164                repo: "extra".into(),
165                arch: "x86_64".into(),
166            },
167            popularity: None,
168            out_of_date: None,
169            orphaned: false,
170        };
171
172        let (cmd1, uses_sudo1) = build_install_command(&pkg, None, false).expect("build");
173        assert!(uses_sudo1);
174        let quoted_name = crate::install::shell_single_quote("ripgrep");
175        assert!(
176            cmd1.contains(&format!(
177                "{bin} pacman -S --needed --noconfirm {quoted_name}"
178            )),
179            "expected quoted package name in: {cmd1}"
180        );
181        assert!(cmd1.contains("Press any key to close"));
182
183        let (cmd2, uses_sudo2) = build_install_command(&pkg, Some("pa's"), false).expect("build");
184        assert!(uses_sudo2);
185        if tool.capabilities().supports_stdin_password {
186            assert!(
187                cmd2.contains(&format!(
188                    "{bin} -S pacman -S --needed --noconfirm {quoted_name}"
189                )),
190                "expected quoted package name in password pipe command: {cmd2}"
191            );
192        } else {
193            assert!(
194                cmd2.contains(&format!(
195                    "{bin} pacman -S --needed --noconfirm {quoted_name}"
196                )),
197                "doas fallback should use plain command: {cmd2}"
198            );
199        }
200
201        let (cmd3, uses_sudo3) = build_install_command(&pkg, None, true).expect("build");
202        assert!(uses_sudo3);
203        assert!(cmd3.starts_with("echo DRY RUN: '"));
204        assert!(
205            cmd3.contains(&format!("{bin} pacman -S --needed --noconfirm"))
206                && cmd3.contains("ripgrep"),
207            "expected dry-run output to include command and package name: {cmd3}"
208        );
209    }
210
211    #[test]
212    /// What: Verify AUR command construction selects the correct helper and respects dry-run output.
213    ///
214    /// Inputs:
215    /// - AUR package metadata.
216    /// - Dry-run flag toggled between `false` and `true`.
217    ///
218    /// Output:
219    /// - Produces scripts that prefer `paru`, fall back to `yay`, and emit a dry-run echo when requested.
220    ///
221    /// Details:
222    /// - Asserts the crafted shell script still includes the hold-tail prompt and missing-helper warning.
223    fn install_build_install_command_aur_variants() {
224        let pkg = PackageItem {
225            name: "yay-bin".into(),
226            version: "1".into(),
227            description: String::new(),
228            source: Source::Aur,
229            popularity: None,
230            out_of_date: None,
231            orphaned: false,
232        };
233
234        let (cmd1, uses_sudo1) = build_install_command(&pkg, None, false).expect("build");
235        assert!(!uses_sudo1);
236        assert!(cmd1.contains("command -v paru"));
237        assert!(cmd1.contains("paru -S --aur --needed --noconfirm 'yay-bin'"));
238        assert!(cmd1.contains("yay -S --aur --needed --noconfirm 'yay-bin'"));
239        assert!(cmd1.contains("elif command -v yay"));
240        assert!(cmd1.contains("No AUR helper"));
241        assert!(cmd1.contains("Press any key to close"));
242
243        let (cmd2, uses_sudo2) = build_install_command(&pkg, None, true).expect("build");
244        assert!(!uses_sudo2);
245        // Dry-run commands are now properly quoted to avoid syntax errors
246        assert!(cmd2.starts_with("echo DRY RUN: '"));
247        assert!(cmd2.contains("paru -S --aur --needed --noconfirm"));
248        assert!(cmd2.contains("yay-bin"));
249    }
250}