Skip to main content

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) = match build_install_command(item, password, dry_run) {
145        Ok(v) => v,
146        Err(err) => {
147            tracing::error!(error = %err, names = %item.name, "privilege tool resolution failed for install");
148            return;
149        }
150    };
151    let src = match item.source {
152        Source::Official { .. } => "official",
153        Source::Aur => "aur",
154    };
155    tracing::info!(
156        names = %item.name,
157        total = 1,
158        aur_count = usize::from(src == "aur"),
159        official_count = usize::from(src == "official"),
160        dry_run = dry_run,
161        uses_sudo,
162        "spawning install"
163    );
164
165    let terms = get_terminal_preferences();
166
167    // Try preferred path-based selection first
168    let mut launched = choose_terminal_index_prefer_path(terms).is_some_and(|idx| {
169        let (term, args, needs_xfce_command) = terms[idx];
170        try_spawn_terminal(
171            term,
172            args,
173            needs_xfce_command,
174            &cmd_str,
175            &item.name,
176            src,
177            dry_run,
178        )
179    });
180
181    // Fallback: try each terminal in order
182    if !launched {
183        for (term, args, needs_xfce_command) in terms {
184            if command_on_path(term) {
185                launched = try_spawn_terminal(
186                    term,
187                    args,
188                    *needs_xfce_command,
189                    &cmd_str,
190                    &item.name,
191                    src,
192                    dry_run,
193                );
194                if launched {
195                    break;
196                }
197            }
198        }
199    }
200
201    // Final fallback: use bash directly
202    if !launched {
203        let res = Command::new("bash").args(["-lc", &cmd_str]).spawn();
204        if let Err(e) = res {
205            tracing::error!(error = %e, names = %item.name, "failed to spawn bash to run install command");
206        } else {
207            tracing::info!(
208                names = %item.name,
209                total = 1,
210                aur_count = usize::from(src == "aur"),
211                official_count = usize::from(src == "official"),
212                dry_run = dry_run,
213                "launched bash for install"
214            );
215        }
216    }
217
218    if !dry_run && let Err(e) = log_installed(std::slice::from_ref(&item.name)) {
219        tracing::warn!(error = %e, names = %item.name, "failed to write install audit log");
220    }
221}
222
223#[cfg(all(test, not(target_os = "windows")))]
224mod tests {
225    #[test]
226    /// What: Confirm the single-install helper launches gnome-terminal with the expected separator arguments.
227    ///
228    /// Inputs:
229    /// - Shim `gnome-terminal` placed first on `PATH` capturing its argv.
230    /// - `spawn_install` invoked in dry-run mode for an official package.
231    ///
232    /// Output:
233    /// - Captured arguments begin with `--`, `bash`, `-lc`, matching the safe invocation contract.
234    ///
235    /// Details:
236    /// - Creates temporary directory to host the shim binary, exports `PACSEA_TEST_OUT`, then restores environment variables afterward.
237    fn install_single_uses_gnome_terminal_double_dash() {
238        use std::fs;
239        use std::os::unix::fs::PermissionsExt;
240        use std::path::PathBuf;
241
242        let mut dir: PathBuf = std::env::temp_dir();
243        dir.push(format!(
244            "pacsea_test_inst_single_gnome_{}_{}",
245            std::process::id(),
246            std::time::SystemTime::now()
247                .duration_since(std::time::UNIX_EPOCH)
248                .expect("System time is before UNIX epoch")
249                .as_nanos()
250        ));
251        fs::create_dir_all(&dir).expect("Failed to create test directory");
252        let mut out_path = dir.clone();
253        out_path.push("args.txt");
254        let mut term_path = dir.clone();
255        term_path.push("gnome-terminal");
256        let script = "#!/bin/sh\n: > \"$PACSEA_TEST_OUT\"\nfor a in \"$@\"; do printf '%s\n' \"$a\" >> \"$PACSEA_TEST_OUT\"; done\n";
257        fs::write(&term_path, script.as_bytes()).expect("Failed to write test terminal script");
258        let mut perms = fs::metadata(&term_path)
259            .expect("Failed to read test terminal script metadata")
260            .permissions();
261        perms.set_mode(0o755);
262        fs::set_permissions(&term_path, perms)
263            .expect("Failed to set test terminal script permissions");
264
265        let orig_path = std::env::var_os("PATH");
266        unsafe {
267            std::env::set_var("PATH", dir.display().to_string());
268            std::env::set_var("PACSEA_TEST_OUT", out_path.display().to_string());
269        }
270
271        let pkg = crate::state::PackageItem {
272            name: "ripgrep".into(),
273            version: "1".into(),
274            description: String::new(),
275            source: crate::state::Source::Official {
276                repo: "extra".into(),
277                arch: "x86_64".into(),
278            },
279            popularity: None,
280            out_of_date: None,
281            orphaned: false,
282        };
283        super::spawn_install(&pkg, None, true);
284        std::thread::sleep(std::time::Duration::from_millis(50));
285
286        let body = fs::read_to_string(&out_path).expect("fake terminal args file written");
287        let lines: Vec<&str> = body.lines().collect();
288        assert!(lines.len() >= 3, "expected at least 3 args, got: {body}");
289        assert_eq!(lines[0], "--");
290        assert_eq!(lines[1], "bash");
291        assert_eq!(lines[2], "-lc");
292
293        unsafe {
294            if let Some(v) = orig_path {
295                std::env::set_var("PATH", v);
296            } else {
297                std::env::remove_var("PATH");
298            }
299            std::env::remove_var("PACSEA_TEST_OUT");
300        }
301    }
302}
303
304#[cfg(target_os = "windows")]
305/// What: Present a placeholder install message on Windows where pacman/AUR helpers are unavailable.
306///
307/// Input:
308/// - `item`: Package metadata used to build the informational command.
309/// - `password`: Ignored; included for API parity.
310/// - `dry_run`: When `true`, uses `PowerShell` to simulate the install operation.
311///
312/// Output:
313/// - Launches a detached `PowerShell` window (if available) for dry-run simulation, or `cmd` window otherwise.
314///
315/// Details:
316/// - When `dry_run` is true and `PowerShell` is available, uses `PowerShell` to simulate the install with Write-Host.
317/// - Logs the install attempt when not a dry run to keep audit behaviour consistent with Unix platforms.
318/// - During tests, this is a no-op to avoid opening real terminal windows.
319#[allow(unused_variables, clippy::missing_const_for_fn)]
320pub fn spawn_install(item: &PackageItem, password: Option<&str>, dry_run: bool) {
321    #[cfg(not(test))]
322    {
323        let (cmd_str, _uses_sudo) = match build_install_command(item, password, dry_run) {
324            Ok(v) => v,
325            Err(err) => {
326                tracing::error!(error = %err, "privilege tool resolution failed for install");
327                return;
328            }
329        };
330
331        if dry_run && super::utils::is_powershell_available() {
332            // Use PowerShell to simulate the install operation
333            let powershell_cmd = format!(
334                "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')",
335                item.name,
336                cmd_str.replace('\'', "''")
337            );
338            let _ = Command::new("powershell.exe")
339                .args(["-NoProfile", "-Command", &powershell_cmd])
340                .spawn();
341        } else {
342            let _ = Command::new("cmd")
343                .args(["/C", "start", "Pacsea Install", "cmd", "/K", &cmd_str])
344                .spawn();
345        }
346
347        if !dry_run {
348            let _ = super::logging::log_installed(std::slice::from_ref(&item.name));
349        }
350    }
351}