pacsea/install/
single.rs

1#[allow(unused_imports)]
2use std::process::Command;
3
4use crate::state::PackageItem;
5#[cfg(not(target_os = "windows"))]
6use crate::state::Source;
7
8#[cfg(not(target_os = "windows"))]
9use super::command::build_install_command;
10#[cfg(all(target_os = "windows", not(test)))]
11use super::command::build_install_command;
12#[cfg(not(target_os = "windows"))]
13use super::logging::log_installed;
14#[cfg(not(target_os = "windows"))]
15use super::utils::{choose_terminal_index_prefer_path, command_on_path, shell_single_quote};
16
17#[cfg(not(target_os = "windows"))]
18/// What: Attempt to spawn a terminal with the given command.
19///
20/// Input:
21/// - `term`: Terminal binary name
22/// - `args`: Arguments to pass to the terminal
23/// - `needs_xfce_command`: Whether this terminal needs special xfce4-terminal command handling
24/// - `cmd_str`: The install command to execute
25/// - `item_name`: Package name for logging
26/// - `src`: Source type ("official" or "aur") for logging
27/// - `dry_run`: Whether this is a dry run
28///
29/// Output:
30/// - `true` if the terminal was successfully spawned, `false` otherwise
31///
32/// Details:
33/// - Handles `xfce4-terminal` special command format and sets up `PACSEA_TEST_OUT` environment variable if needed.
34fn try_spawn_terminal(
35    term: &str,
36    args: &[&str],
37    needs_xfce_command: bool,
38    cmd_str: &str,
39    item_name: &str,
40    src: &str,
41    dry_run: bool,
42) -> bool {
43    let mut cmd = Command::new(term);
44    if needs_xfce_command && term == "xfce4-terminal" {
45        let quoted = shell_single_quote(cmd_str);
46        cmd.arg("--command").arg(format!("bash -lc {quoted}"));
47    } else {
48        cmd.args(args.iter().copied()).arg(cmd_str);
49    }
50    if let Ok(p) = std::env::var("PACSEA_TEST_OUT") {
51        if let Some(parent) = std::path::Path::new(&p).parent() {
52            let _ = std::fs::create_dir_all(parent);
53        }
54        cmd.env("PACSEA_TEST_OUT", p);
55    }
56    match cmd.spawn() {
57        Ok(_) => {
58            tracing::info!(
59                terminal = %term,
60                names = %item_name,
61                total = 1,
62                aur_count = usize::from(src == "aur"),
63                official_count = usize::from(src == "official"),
64                dry_run,
65                "launched terminal for install"
66            );
67            true
68        }
69        Err(e) => {
70            tracing::warn!(
71                terminal = %term,
72                error = %e,
73                names = %item_name,
74                "failed to spawn terminal, trying next"
75            );
76            false
77        }
78    }
79}
80
81#[cfg(not(target_os = "windows"))]
82/// What: Get the terminal preference list based on desktop environment.
83///
84/// Input:
85/// - None (reads `XDG_CURRENT_DESKTOP` environment variable)
86///
87/// Output:
88/// - Slice of terminal tuples `(name, args, needs_xfce_command)` ordered by preference
89///
90/// Details:
91/// - Prefers GNOME terminals when running under GNOME desktop, otherwise uses default ordering.
92fn get_terminal_preferences() -> &'static [(&'static str, &'static [&'static str], bool)] {
93    let is_gnome = std::env::var("XDG_CURRENT_DESKTOP")
94        .ok()
95        .is_some_and(|v| v.to_uppercase().contains("GNOME"));
96    if is_gnome {
97        &[
98            ("gnome-terminal", &["--", "bash", "-lc"], false),
99            ("gnome-console", &["--", "bash", "-lc"], false),
100            ("kgx", &["--", "bash", "-lc"], false),
101            ("alacritty", &["-e", "bash", "-lc"], false),
102            ("kitty", &["bash", "-lc"], false),
103            ("xterm", &["-hold", "-e", "bash", "-lc"], false),
104            ("konsole", &["-e", "bash", "-lc"], false),
105            ("xfce4-terminal", &[], true),
106            ("tilix", &["--", "bash", "-lc"], false),
107            ("mate-terminal", &["--", "bash", "-lc"], false),
108        ]
109    } else {
110        &[
111            ("alacritty", &["-e", "bash", "-lc"], false),
112            ("kitty", &["bash", "-lc"], false),
113            ("xterm", &["-hold", "-e", "bash", "-lc"], false),
114            ("gnome-terminal", &["--", "bash", "-lc"], false),
115            ("gnome-console", &["--", "bash", "-lc"], false),
116            ("kgx", &["--", "bash", "-lc"], false),
117            ("konsole", &["-e", "bash", "-lc"], false),
118            ("xfce4-terminal", &[], true),
119            ("tilix", &["--", "bash", "-lc"], false),
120            ("mate-terminal", &["--", "bash", "-lc"], false),
121        ]
122    }
123}
124
125#[cfg(not(target_os = "windows"))]
126/// What: Spawn a terminal to install a single package.
127///
128/// Input:
129/// - item to install; password for sudo on official installs (optional); `dry_run` to print instead of execute
130///
131/// Output:
132/// - Launches a terminal (or `bash`) running `pacman`/`paru`/`yay` to perform the install
133///
134/// Details:
135/// - Prefers common terminals (`GNOME Console`/`Terminal`, `kitty`, `alacritty`, `xterm`, `xfce4-terminal`, etc.), falling back to `bash`. Uses `pacman` for official packages and `paru`/`yay` for AUR; appends a hold tail to keep the window open; logs installed names when not in `dry_run`.
136/// - During tests, this is a no-op to avoid opening real terminal windows.
137pub fn spawn_install(item: &PackageItem, password: Option<&str>, dry_run: bool) {
138    // Skip actual spawning during tests unless PACSEA_TEST_OUT is set (indicates a test with fake terminal)
139    #[cfg(test)]
140    if std::env::var("PACSEA_TEST_OUT").is_err() {
141        return;
142    }
143
144    let (cmd_str, uses_sudo) = build_install_command(item, password, dry_run);
145    let src = match item.source {
146        Source::Official { .. } => "official",
147        Source::Aur => "aur",
148    };
149    tracing::info!(
150        names = %item.name,
151        total = 1,
152        aur_count = usize::from(src == "aur"),
153        official_count = usize::from(src == "official"),
154        dry_run = dry_run,
155        uses_sudo,
156        "spawning install"
157    );
158
159    let terms = get_terminal_preferences();
160
161    // Try preferred path-based selection first
162    let mut launched = choose_terminal_index_prefer_path(terms).is_some_and(|idx| {
163        let (term, args, needs_xfce_command) = terms[idx];
164        try_spawn_terminal(
165            term,
166            args,
167            needs_xfce_command,
168            &cmd_str,
169            &item.name,
170            src,
171            dry_run,
172        )
173    });
174
175    // Fallback: try each terminal in order
176    if !launched {
177        for (term, args, needs_xfce_command) in terms {
178            if command_on_path(term) {
179                launched = try_spawn_terminal(
180                    term,
181                    args,
182                    *needs_xfce_command,
183                    &cmd_str,
184                    &item.name,
185                    src,
186                    dry_run,
187                );
188                if launched {
189                    break;
190                }
191            }
192        }
193    }
194
195    // Final fallback: use bash directly
196    if !launched {
197        let res = Command::new("bash").args(["-lc", &cmd_str]).spawn();
198        if let Err(e) = res {
199            tracing::error!(error = %e, names = %item.name, "failed to spawn bash to run install command");
200        } else {
201            tracing::info!(
202                names = %item.name,
203                total = 1,
204                aur_count = usize::from(src == "aur"),
205                official_count = usize::from(src == "official"),
206                dry_run = dry_run,
207                "launched bash for install"
208            );
209        }
210    }
211
212    if !dry_run && let Err(e) = log_installed(std::slice::from_ref(&item.name)) {
213        tracing::warn!(error = %e, names = %item.name, "failed to write install audit log");
214    }
215}
216
217#[cfg(all(test, not(target_os = "windows")))]
218mod tests {
219    #[test]
220    /// What: Confirm the single-install helper launches gnome-terminal with the expected separator arguments.
221    ///
222    /// Inputs:
223    /// - Shim `gnome-terminal` placed first on `PATH` capturing its argv.
224    /// - `spawn_install` invoked in dry-run mode for an official package.
225    ///
226    /// Output:
227    /// - Captured arguments begin with `--`, `bash`, `-lc`, matching the safe invocation contract.
228    ///
229    /// Details:
230    /// - Creates temporary directory to host the shim binary, exports `PACSEA_TEST_OUT`, then restores environment variables afterward.
231    fn install_single_uses_gnome_terminal_double_dash() {
232        use std::fs;
233        use std::os::unix::fs::PermissionsExt;
234        use std::path::PathBuf;
235
236        let mut dir: PathBuf = std::env::temp_dir();
237        dir.push(format!(
238            "pacsea_test_inst_single_gnome_{}_{}",
239            std::process::id(),
240            std::time::SystemTime::now()
241                .duration_since(std::time::UNIX_EPOCH)
242                .expect("System time is before UNIX epoch")
243                .as_nanos()
244        ));
245        fs::create_dir_all(&dir).expect("Failed to create test directory");
246        let mut out_path = dir.clone();
247        out_path.push("args.txt");
248        let mut term_path = dir.clone();
249        term_path.push("gnome-terminal");
250        let script = "#!/bin/sh\n: > \"$PACSEA_TEST_OUT\"\nfor a in \"$@\"; do printf '%s\n' \"$a\" >> \"$PACSEA_TEST_OUT\"; done\n";
251        fs::write(&term_path, script.as_bytes()).expect("Failed to write test terminal script");
252        let mut perms = fs::metadata(&term_path)
253            .expect("Failed to read test terminal script metadata")
254            .permissions();
255        perms.set_mode(0o755);
256        fs::set_permissions(&term_path, perms)
257            .expect("Failed to set test terminal script permissions");
258
259        let orig_path = std::env::var_os("PATH");
260        unsafe {
261            std::env::set_var("PATH", dir.display().to_string());
262            std::env::set_var("PACSEA_TEST_OUT", out_path.display().to_string());
263        }
264
265        let pkg = crate::state::PackageItem {
266            name: "ripgrep".into(),
267            version: "1".into(),
268            description: String::new(),
269            source: crate::state::Source::Official {
270                repo: "extra".into(),
271                arch: "x86_64".into(),
272            },
273            popularity: None,
274            out_of_date: None,
275            orphaned: false,
276        };
277        super::spawn_install(&pkg, None, true);
278        std::thread::sleep(std::time::Duration::from_millis(50));
279
280        let body = fs::read_to_string(&out_path).expect("fake terminal args file written");
281        let lines: Vec<&str> = body.lines().collect();
282        assert!(lines.len() >= 3, "expected at least 3 args, got: {body}");
283        assert_eq!(lines[0], "--");
284        assert_eq!(lines[1], "bash");
285        assert_eq!(lines[2], "-lc");
286
287        unsafe {
288            if let Some(v) = orig_path {
289                std::env::set_var("PATH", v);
290            } else {
291                std::env::remove_var("PATH");
292            }
293            std::env::remove_var("PACSEA_TEST_OUT");
294        }
295    }
296}
297
298#[cfg(target_os = "windows")]
299/// What: Present a placeholder install message on Windows where pacman/AUR helpers are unavailable.
300///
301/// Input:
302/// - `item`: Package metadata used to build the informational command.
303/// - `password`: Ignored; included for API parity.
304/// - `dry_run`: When `true`, uses `PowerShell` to simulate the install operation.
305///
306/// Output:
307/// - Launches a detached `PowerShell` window (if available) for dry-run simulation, or `cmd` window otherwise.
308///
309/// Details:
310/// - When `dry_run` is true and `PowerShell` is available, uses `PowerShell` to simulate the install with Write-Host.
311/// - Logs the install attempt when not a dry run to keep audit behaviour consistent with Unix platforms.
312/// - During tests, this is a no-op to avoid opening real terminal windows.
313#[allow(unused_variables, clippy::missing_const_for_fn)]
314pub fn spawn_install(item: &PackageItem, password: Option<&str>, dry_run: bool) {
315    #[cfg(not(test))]
316    {
317        let (cmd_str, _uses_sudo) = build_install_command(item, password, dry_run);
318
319        if dry_run && super::utils::is_powershell_available() {
320            // Use PowerShell to simulate the install operation
321            let powershell_cmd = format!(
322                "Write-Host 'DRY RUN: Simulating install of {}' -ForegroundColor Yellow; Write-Host 'Command: {}' -ForegroundColor Cyan; Write-Host ''; Write-Host 'Press any key to close...'; $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')",
323                item.name,
324                cmd_str.replace('\'', "''")
325            );
326            let _ = Command::new("powershell.exe")
327                .args(["-NoProfile", "-Command", &powershell_cmd])
328                .spawn();
329        } else {
330            let _ = Command::new("cmd")
331                .args(["/C", "start", "Pacsea Install", "cmd", "/K", &cmd_str])
332                .spawn();
333        }
334
335        if !dry_run {
336            let _ = super::logging::log_installed(std::slice::from_ref(&item.name));
337        }
338    }
339}