pacsea/install/
batch.rs

1#[cfg(not(target_os = "windows"))]
2use crate::state::Source;
3#[allow(unused_imports)]
4use std::process::Command;
5
6use crate::state::PackageItem;
7
8#[cfg(not(target_os = "windows"))]
9use super::command::aur_install_body;
10#[cfg(not(target_os = "windows"))]
11use super::logging::log_installed;
12#[cfg(not(target_os = "windows"))]
13use super::utils::{choose_terminal_index_prefer_path, command_on_path, shell_single_quote};
14
15#[cfg(not(target_os = "windows"))]
16/// What: Build the shell command string for batch package installation.
17///
18/// Input:
19/// - `items`: Packages to install
20/// - `official`: Names of official packages
21/// - `aur`: Names of AUR packages
22/// - `dry_run`: When `true`, prints commands instead of executing
23///
24/// Output:
25/// - Shell command string with hold tail appended
26///
27/// Details:
28/// - Official packages are grouped into a single `pacman` invocation
29/// - AUR packages are installed via `paru`/`yay` (prompts to install a helper if missing)
30/// - Appends a "hold" tail so the terminal remains open after command completion
31fn build_batch_install_command(
32    items: &[PackageItem],
33    official: &[String],
34    aur: &[String],
35    dry_run: bool,
36) -> String {
37    let hold_tail = "; echo; echo 'Finished.'; echo 'Press any key to close...'; read -rn1 -s _ || (echo; echo 'Press Ctrl+C to close'; sleep infinity)";
38
39    if dry_run {
40        if !aur.is_empty() {
41            let all: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
42            let cmd = format!(
43                "(paru -S --needed --noconfirm {n} || yay -S --needed --noconfirm {n}){hold}",
44                n = all.join(" "),
45                hold = hold_tail
46            );
47            let quoted = shell_single_quote(&cmd);
48            format!("echo DRY RUN: {quoted}")
49        } else if !official.is_empty() {
50            let cmd = format!(
51                "sudo pacman -S --needed --noconfirm {n}{hold}",
52                n = official.join(" "),
53                hold = hold_tail
54            );
55            let quoted = shell_single_quote(&cmd);
56            format!("echo DRY RUN: {quoted}")
57        } else {
58            format!("echo DRY RUN: nothing to install{hold_tail}")
59        }
60    } else if !aur.is_empty() {
61        let all: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
62        let n = all.join(" ");
63        format!(
64            "{body}{hold}",
65            body = aur_install_body("-S --needed --noconfirm", &n),
66            hold = hold_tail
67        )
68    } else if !official.is_empty() {
69        // Check if any packages have version info (coming from updates window)
70        let has_versions = items
71            .iter()
72            .any(|item| matches!(item.source, Source::Official { .. }) && !item.version.is_empty());
73        let reinstall_any = items.iter().any(|item| {
74            matches!(item.source, Source::Official { .. }) && crate::index::is_installed(&item.name)
75        });
76
77        if has_versions && reinstall_any {
78            // Coming from updates window - sync database first, then install
79            format!(
80                "sudo bash -c 'pacman -Sy --noconfirm && pacman -S --noconfirm {n}'{hold}",
81                n = official.join(" "),
82                hold = hold_tail
83            )
84        } else {
85            format!(
86                "sudo pacman -S --needed --noconfirm {n}{hold}",
87                n = official.join(" "),
88                hold = hold_tail
89            )
90        }
91    } else {
92        format!("echo nothing to install{hold_tail}")
93    }
94}
95
96#[cfg(not(target_os = "windows"))]
97/// What: Attempt to spawn a terminal with the given command string.
98///
99/// Input:
100/// - `term`: Terminal executable name
101/// - `args`: Arguments for the terminal
102/// - `needs_xfce_command`: Whether this terminal needs special xfce4-terminal command handling
103/// - `cmd_str`: Command string to execute in the terminal
104///
105/// Output:
106/// - `Ok(())` if the terminal was successfully spawned, `Err(())` otherwise
107///
108/// Details:
109/// - Handles special cases for `konsole` (`Wayland`), `gnome-console`/`kgx` (rendering), and `xfce4-terminal` (command format)
110/// - Sets up `PACSEA_TEST_OUT` environment variable if present
111fn try_spawn_terminal(
112    term: &str,
113    args: &[&str],
114    needs_xfce_command: bool,
115    cmd_str: &str,
116) -> Result<(), ()> {
117    let mut cmd = Command::new(term);
118    if needs_xfce_command && term == "xfce4-terminal" {
119        let quoted = shell_single_quote(cmd_str);
120        cmd.arg("--command").arg(format!("bash -lc {quoted}"));
121    } else {
122        cmd.args(args.iter().copied()).arg(cmd_str);
123    }
124    if let Ok(p) = std::env::var("PACSEA_TEST_OUT") {
125        if let Some(parent) = std::path::Path::new(&p).parent() {
126            let _ = std::fs::create_dir_all(parent);
127        }
128        cmd.env("PACSEA_TEST_OUT", p);
129    }
130    if term == "konsole" && std::env::var_os("WAYLAND_DISPLAY").is_some() {
131        cmd.env("QT_LOGGING_RULES", "qt.qpa.wayland.textinput=false");
132    }
133    if term == "gnome-console" || term == "kgx" {
134        cmd.env("GSK_RENDERER", "cairo");
135        cmd.env("LIBGL_ALWAYS_SOFTWARE", "1");
136    }
137    cmd.spawn().map(|_| ()).map_err(|_| ())
138}
139
140#[cfg(not(target_os = "windows"))]
141/// What: Spawn a terminal to install a batch of packages.
142///
143/// Input:
144/// - `items`: Packages to install
145/// - `dry_run`: When `true`, prints commands instead of executing
146///
147/// Output:
148/// - Launches a terminal (or falls back to `bash`) running the composed install commands.
149///
150/// Details:
151/// - Official packages are grouped into a single `pacman` invocation
152/// - AUR packages are installed via `paru`/`yay` (prompts to install a helper if missing)
153/// - Prefers common terminals (GNOME Console/Terminal, kitty, alacritty, xterm, xfce4-terminal, etc.); falls back to `bash`
154/// - Appends a "hold" tail so the terminal remains open after command completion
155/// - During tests, this is a no-op to avoid opening real terminal windows.
156pub fn spawn_install_all(items: &[PackageItem], dry_run: bool) {
157    // Skip actual spawning during tests unless PACSEA_TEST_OUT is set (indicates a test with fake terminal)
158    #[cfg(test)]
159    if std::env::var("PACSEA_TEST_OUT").is_err() {
160        return;
161    }
162
163    let mut official: Vec<String> = Vec::new();
164    let mut aur: Vec<String> = Vec::new();
165    for it in items {
166        match it.source {
167            Source::Official { .. } => official.push(it.name.clone()),
168            Source::Aur => aur.push(it.name.clone()),
169        }
170    }
171    let names_vec: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
172    tracing::info!(
173        total = items.len(),
174        aur_count = aur.len(),
175        official_count = official.len(),
176        dry_run = dry_run,
177        names = %names_vec.join(" "),
178        "spawning install"
179    );
180
181    let cmd_str = build_batch_install_command(items, &official, &aur, dry_run);
182
183    // Prefer GNOME Terminal when running under GNOME desktop
184    let is_gnome = std::env::var("XDG_CURRENT_DESKTOP")
185        .ok()
186        .is_some_and(|v| v.to_uppercase().contains("GNOME"));
187    let terms_gnome_first: &[(&str, &[&str], bool)] = &[
188        ("gnome-terminal", &["--", "bash", "-lc"], false),
189        ("gnome-console", &["--", "bash", "-lc"], false),
190        ("kgx", &["--", "bash", "-lc"], false),
191        ("alacritty", &["-e", "bash", "-lc"], false),
192        ("kitty", &["bash", "-lc"], false),
193        ("xterm", &["-hold", "-e", "bash", "-lc"], false),
194        ("konsole", &["-e", "bash", "-lc"], false),
195        ("xfce4-terminal", &[], true),
196        ("tilix", &["--", "bash", "-lc"], false),
197        ("mate-terminal", &["--", "bash", "-lc"], false),
198    ];
199    let terms_default: &[(&str, &[&str], bool)] = &[
200        ("alacritty", &["-e", "bash", "-lc"], false),
201        ("kitty", &["bash", "-lc"], false),
202        ("xterm", &["-hold", "-e", "bash", "-lc"], false),
203        ("gnome-terminal", &["--", "bash", "-lc"], false),
204        ("gnome-console", &["--", "bash", "-lc"], false),
205        ("kgx", &["--", "bash", "-lc"], false),
206        ("konsole", &["-e", "bash", "-lc"], false),
207        ("xfce4-terminal", &[], true),
208        ("tilix", &["--", "bash", "-lc"], false),
209        ("mate-terminal", &["--", "bash", "-lc"], false),
210    ];
211    let terms = if is_gnome {
212        terms_gnome_first
213    } else {
214        terms_default
215    };
216    let mut launched = false;
217    if let Some(idx) = choose_terminal_index_prefer_path(terms) {
218        let (term, args, needs_xfce_command) = terms[idx];
219        match try_spawn_terminal(term, args, needs_xfce_command, &cmd_str) {
220            Ok(()) => {
221                tracing::info!(terminal = %term, total = items.len(), aur_count = aur.len(), official_count = official.len(), dry_run = dry_run, names = %names_vec.join(" "), "launched terminal for install");
222                launched = true;
223            }
224            Err(()) => {
225                tracing::warn!(terminal = %term, names = %names_vec.join(" "), "failed to spawn terminal, trying next");
226            }
227        }
228    }
229
230    if !launched {
231        for (term, args, needs_xfce_command) in terms {
232            if command_on_path(term) {
233                match try_spawn_terminal(term, args, *needs_xfce_command, &cmd_str) {
234                    Ok(()) => {
235                        tracing::info!(terminal = %term, total = items.len(), aur_count = aur.len(), official_count = official.len(), dry_run = dry_run, names = %names_vec.join(" "), "launched terminal for install");
236                        launched = true;
237                        break;
238                    }
239                    Err(()) => {
240                        tracing::warn!(terminal = %term, names = %names_vec.join(" "), "failed to spawn terminal, trying next");
241                    }
242                }
243            }
244        }
245    }
246    if !launched {
247        let res = Command::new("bash").args(["-lc", &cmd_str]).spawn();
248        if let Err(e) = res {
249            tracing::error!(error = %e, names = %names_vec.join(" "), "failed to spawn bash to run install command");
250        } else {
251            tracing::info!(total = items.len(), aur_count = aur.len(), official_count = official.len(), dry_run = dry_run, names = %names_vec.join(" "), "launched bash for install");
252        }
253    }
254
255    if !dry_run {
256        let names: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
257        if !names.is_empty()
258            && let Err(e) = log_installed(&names)
259        {
260            tracing::warn!(error = %e, count = names.len(), "failed to write install audit log");
261        }
262    }
263}
264
265#[cfg(all(test, not(target_os = "windows")))]
266mod tests {
267    #[test]
268    /// What: Confirm batch installs launch gnome-terminal with the expected separator arguments.
269    ///
270    /// Inputs:
271    /// - Shim `gnome-terminal` scripted to capture argv via `PACSEA_TEST_OUT`.
272    /// - `spawn_install_all` invoked with two official packages in dry-run mode.
273    ///
274    /// Output:
275    /// - Captured argument list starts with `--`, `bash`, `-lc`, validating safe command invocation.
276    ///
277    /// Details:
278    /// - Overrides `PATH` and environment variables, then restores them to avoid leaking state across tests.
279    fn install_batch_uses_gnome_terminal_double_dash() {
280        use std::fs;
281        use std::os::unix::fs::PermissionsExt;
282        use std::path::PathBuf;
283
284        let mut dir: PathBuf = std::env::temp_dir();
285        dir.push(format!(
286            "pacsea_test_inst_batch_gnome_{}_{}",
287            std::process::id(),
288            std::time::SystemTime::now()
289                .duration_since(std::time::UNIX_EPOCH)
290                .expect("System time is before UNIX epoch")
291                .as_nanos()
292        ));
293        let _ = fs::create_dir_all(&dir);
294        let mut out_path = dir.clone();
295        out_path.push("args.txt");
296        let mut term_path = dir.clone();
297        term_path.push("gnome-terminal");
298        let script = "#!/bin/sh\n: > \"$PACSEA_TEST_OUT\"\nfor a in \"$@\"; do printf '%s\n' \"$a\" >> \"$PACSEA_TEST_OUT\"; done\n";
299        fs::write(&term_path, script.as_bytes()).expect("Failed to write test terminal script");
300        let mut perms = fs::metadata(&term_path)
301            .expect("Failed to read test terminal script metadata")
302            .permissions();
303        perms.set_mode(0o755);
304        fs::set_permissions(&term_path, perms)
305            .expect("Failed to set test terminal script permissions");
306
307        let orig_path = std::env::var_os("PATH");
308        unsafe {
309            std::env::set_var("PATH", dir.display().to_string());
310            std::env::set_var("PACSEA_TEST_OUT", out_path.display().to_string());
311        }
312
313        let items = vec![
314            crate::state::PackageItem {
315                name: "rg".into(),
316                version: "1".into(),
317                description: String::new(),
318                source: crate::state::Source::Official {
319                    repo: "extra".into(),
320                    arch: "x86_64".into(),
321                },
322                popularity: None,
323                out_of_date: None,
324                orphaned: false,
325            },
326            crate::state::PackageItem {
327                name: "fd".into(),
328                version: "1".into(),
329                description: String::new(),
330                source: crate::state::Source::Official {
331                    repo: "extra".into(),
332                    arch: "x86_64".into(),
333                },
334                popularity: None,
335                out_of_date: None,
336                orphaned: false,
337            },
338        ];
339        super::spawn_install_all(&items, true);
340        std::thread::sleep(std::time::Duration::from_millis(50));
341
342        let body = fs::read_to_string(&out_path).expect("fake terminal args file written");
343        let lines: Vec<&str> = body.lines().collect();
344        assert!(lines.len() >= 3, "expected at least 3 args, got: {body}");
345        assert_eq!(lines[0], "--");
346        assert_eq!(lines[1], "bash");
347        assert_eq!(lines[2], "-lc");
348
349        unsafe {
350            if let Some(v) = orig_path {
351                std::env::set_var("PATH", v);
352            } else {
353                std::env::remove_var("PATH");
354            }
355            std::env::remove_var("PACSEA_TEST_OUT");
356        }
357    }
358}
359
360#[cfg(target_os = "windows")]
361/// What: Present an informational install message on Windows where package management is unsupported.
362///
363/// Input:
364/// - `items`: Packages the user attempted to install.
365/// - `dry_run`: When `true`, uses `PowerShell` to simulate the install operation.
366///
367/// Output:
368/// - Launches a detached `PowerShell` window (if available) for dry-run simulation, or `cmd` window otherwise.
369///
370/// Details:
371/// - When `dry_run` is true and `PowerShell` is available, uses `PowerShell` to simulate the batch install with Write-Host.
372/// - Always logs install attempts when not in `dry_run` to remain consistent with Unix behaviour.
373/// - During tests, this is a no-op to avoid opening real terminal windows.
374#[allow(unused_variables, clippy::missing_const_for_fn)]
375pub fn spawn_install_all(items: &[PackageItem], dry_run: bool) {
376    #[cfg(not(test))]
377    {
378        let mut names: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
379        if names.is_empty() {
380            names.push("nothing".into());
381        }
382        let names_str = names.join(" ");
383
384        if dry_run && super::utils::is_powershell_available() {
385            // Use PowerShell to simulate the batch install operation
386            let powershell_cmd = format!(
387                "Write-Host 'DRY RUN: Simulating batch install of {}' -ForegroundColor Yellow; Write-Host 'Packages: {}' -ForegroundColor Cyan; Write-Host ''; Write-Host 'Press any key to close...'; $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')",
388                names.len(),
389                names_str.replace('\'', "''")
390            );
391            let _ = Command::new("powershell.exe")
392                .args(["-NoProfile", "-Command", &powershell_cmd])
393                .spawn();
394        } else {
395            let msg = if dry_run {
396                format!("DRY RUN: install {names_str}")
397            } else {
398                format!("Install {names_str} (not supported on Windows)")
399            };
400            let _ = Command::new("cmd")
401                .args([
402                    "/C",
403                    "start",
404                    "Pacsea Install",
405                    "cmd",
406                    "/K",
407                    &format!("echo {msg}"),
408                ])
409                .spawn();
410        }
411
412        if !dry_run {
413            let _ = super::logging::log_installed(&names);
414        }
415    }
416}