Skip to main content

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