pacsea/install/
remove.rs

1#[allow(unused_imports)]
2use std::process::Command;
3
4use crate::state::modal::CascadeMode;
5
6/// What: Check for configuration directories in `$HOME/PACKAGE_NAME` and `$HOME/.config/PACKAGE_NAME`.
7///
8/// Inputs:
9/// - `package_name`: Name of the package to check for config directories.
10/// - `home`: Home directory path.
11///
12/// Output:
13/// - Vector of found config directory paths.
14///
15/// Details:
16/// - Checks both `$HOME/PACKAGE_NAME` and `$HOME/.config/PACKAGE_NAME`.
17/// - Only returns directories that actually exist.
18#[must_use]
19pub fn check_config_directories(package_name: &str, home: &str) -> Vec<std::path::PathBuf> {
20    use std::path::PathBuf;
21    let mut found_dirs = Vec::new();
22
23    // Check $HOME/PACKAGE_NAME
24    let home_pkg_dir = PathBuf::from(home).join(package_name);
25    if home_pkg_dir.exists() && home_pkg_dir.is_dir() {
26        found_dirs.push(home_pkg_dir);
27    }
28
29    // Check $HOME/.config/PACKAGE_NAME
30    let config_pkg_dir = PathBuf::from(home).join(".config").join(package_name);
31    if config_pkg_dir.exists() && config_pkg_dir.is_dir() {
32        found_dirs.push(config_pkg_dir);
33    }
34
35    found_dirs
36}
37
38#[cfg(not(target_os = "windows"))]
39use super::utils::{choose_terminal_index_prefer_path, command_on_path, shell_single_quote};
40
41#[cfg(not(target_os = "windows"))]
42/// What: Configure terminal-specific environment variables for a command.
43///
44/// Input:
45/// - `cmd`: Command builder to configure.
46/// - `term`: Terminal name to check for special handling.
47///
48/// Output:
49/// - Modifies `cmd` with environment variables for konsole, gnome-console, or kgx if needed.
50///
51/// Details:
52/// - Sets Wayland-specific environment for konsole when running under Wayland.
53/// - Sets rendering environment for gnome-console and kgx to ensure compatibility.
54fn configure_terminal_env(cmd: &mut Command, term: &str) {
55    if term == "konsole" && std::env::var_os("WAYLAND_DISPLAY").is_some() {
56        cmd.env("QT_LOGGING_RULES", "qt.qpa.wayland.textinput=false");
57    }
58    if term == "gnome-console" || term == "kgx" {
59        cmd.env("GSK_RENDERER", "cairo");
60        cmd.env("LIBGL_ALWAYS_SOFTWARE", "1");
61    }
62}
63
64#[cfg(not(target_os = "windows"))]
65/// What: Configure test output environment variable for a command.
66///
67/// Input:
68/// - `cmd`: Command builder to configure.
69///
70/// Output:
71/// - Sets `PACSEA_TEST_OUT` environment variable if present, creating parent directory if needed.
72///
73/// Details:
74/// - Only applies when `PACSEA_TEST_OUT` is set in the environment.
75fn configure_test_env(cmd: &mut Command) {
76    if let Ok(p) = std::env::var("PACSEA_TEST_OUT") {
77        if let Some(parent) = std::path::Path::new(&p).parent() {
78            let _ = std::fs::create_dir_all(parent);
79        }
80        cmd.env("PACSEA_TEST_OUT", p);
81    }
82}
83
84#[cfg(not(target_os = "windows"))]
85/// What: Logging context for terminal spawning operations.
86///
87/// Details:
88/// - Groups related logging parameters to reduce function argument count.
89struct SpawnContext<'a> {
90    /// Comma-separated package names string.
91    names_str: &'a str,
92    /// Number of package names.
93    names_len: usize,
94    /// Whether this is a dry-run operation.
95    dry_run: bool,
96    /// Cascade removal mode.
97    cascade_mode: CascadeMode,
98}
99
100#[cfg(not(target_os = "windows"))]
101/// What: Attempt to spawn a terminal with the given configuration.
102///
103/// Input:
104/// - `term`: Terminal executable name.
105/// - `args`: Arguments to pass before the command string.
106/// - `needs_xfce_command`: Whether this terminal needs special xfce4-terminal command format.
107/// - `cmd_str`: The command string to execute.
108/// - `ctx`: Logging context for the operation.
109///
110/// Output:
111/// - `true` if the terminal was successfully spawned, `false` otherwise.
112///
113/// Details:
114/// - Configures command arguments based on terminal type.
115/// - Sets up environment variables and test output handling.
116/// - Logs success or failure appropriately.
117fn try_spawn_terminal(
118    term: &str,
119    args: &[&str],
120    needs_xfce_command: bool,
121    cmd_str: &str,
122    ctx: &SpawnContext<'_>,
123) -> bool {
124    let mut cmd = Command::new(term);
125    if needs_xfce_command && term == "xfce4-terminal" {
126        let quoted = shell_single_quote(cmd_str);
127        cmd.arg("--command").arg(format!("bash -lc {quoted}"));
128    } else {
129        cmd.args(args.iter().copied()).arg(cmd_str);
130    }
131    configure_test_env(&mut cmd);
132    configure_terminal_env(&mut cmd, term);
133
134    match cmd.spawn() {
135        Ok(_) => {
136            tracing::info!(
137                terminal = %term,
138                names = %ctx.names_str,
139                total = ctx.names_len,
140                dry_run = ctx.dry_run,
141                mode = ?ctx.cascade_mode,
142                "launched terminal for removal"
143            );
144            true
145        }
146        Err(e) => {
147            tracing::warn!(
148                terminal = %term,
149                error = %e,
150                names = %ctx.names_str,
151                "failed to spawn terminal, trying next"
152            );
153            false
154        }
155    }
156}
157
158#[cfg(not(target_os = "windows"))]
159/// What: Spawn a terminal to remove all given packages with pacman.
160///
161/// Input:
162/// - names slice of package names; `dry_run` prints the removal command instead of executing
163///
164/// Output:
165/// - Launches a terminal (or bash) to run sudo pacman -Rns for the provided names.
166///
167/// Details:
168/// - Prefers common terminals (GNOME Console/Terminal, kitty, alacritty, xterm, xfce4-terminal, etc.); falls back to bash. Appends a hold tail so the window remains open after command completion.
169/// - During tests, this is a no-op to avoid opening real terminal windows.
170pub fn spawn_remove_all(names: &[String], dry_run: bool, cascade_mode: CascadeMode) {
171    // Skip actual spawning during tests unless PACSEA_TEST_OUT is set (indicates a test with fake terminal)
172    #[cfg(test)]
173    if std::env::var("PACSEA_TEST_OUT").is_err() {
174        return;
175    }
176
177    let names_str = names.join(" ");
178    tracing::info!(
179        names = %names_str,
180        total = names.len(),
181        dry_run = dry_run,
182        mode = ?cascade_mode,
183        "spawning removal"
184    );
185    let flag = cascade_mode.flag();
186    let hold_tail = "; echo; echo 'Finished.'; echo 'Press any key to close...'; read -rn1 -s _ || (echo; echo 'Press Ctrl+C to close'; sleep infinity)";
187    let cmd_str = if dry_run {
188        let cmd = format!(
189            "sudo pacman {flag} --noconfirm {n}{hold}",
190            flag = flag,
191            n = names.join(" "),
192            hold = hold_tail
193        );
194        let quoted = shell_single_quote(&cmd);
195        format!("echo DRY RUN: {quoted}")
196    } else {
197        format!(
198            "sudo pacman {flag} --noconfirm {n}{hold}",
199            flag = flag,
200            n = names.join(" "),
201            hold = hold_tail
202        )
203    };
204
205    let terms_gnome_first: &[(&str, &[&str], bool)] = &[
206        ("gnome-terminal", &["--", "bash", "-lc"], false),
207        ("gnome-console", &["--", "bash", "-lc"], false),
208        ("kgx", &["--", "bash", "-lc"], false),
209        ("alacritty", &["-e", "bash", "-lc"], false),
210        ("ghostty", &["-e", "bash", "-lc"], false),
211        ("kitty", &["bash", "-lc"], false),
212        ("xterm", &["-hold", "-e", "bash", "-lc"], false),
213        ("konsole", &["-e", "bash", "-lc"], false),
214        ("xfce4-terminal", &[], true),
215        ("tilix", &["--", "bash", "-lc"], false),
216        ("mate-terminal", &["--", "bash", "-lc"], false),
217    ];
218    let terms_default: &[(&str, &[&str], bool)] = &[
219        ("alacritty", &["-e", "bash", "-lc"], false),
220        ("ghostty", &["-e", "bash", "-lc"], false),
221        ("kitty", &["bash", "-lc"], false),
222        ("xterm", &["-hold", "-e", "bash", "-lc"], false),
223        ("gnome-terminal", &["--", "bash", "-lc"], false),
224        ("gnome-console", &["--", "bash", "-lc"], false),
225        ("kgx", &["--", "bash", "-lc"], false),
226        ("konsole", &["-e", "bash", "-lc"], false),
227        ("xfce4-terminal", &[], true),
228        ("tilix", &["--", "bash", "-lc"], false),
229        ("mate-terminal", &["--", "bash", "-lc"], false),
230    ];
231
232    let is_gnome = std::env::var("XDG_CURRENT_DESKTOP")
233        .ok()
234        .is_some_and(|v| v.to_uppercase().contains("GNOME"));
235    let terms = if is_gnome {
236        terms_gnome_first
237    } else {
238        terms_default
239    };
240
241    let ctx = SpawnContext {
242        names_str: &names_str,
243        names_len: names.len(),
244        dry_run,
245        cascade_mode,
246    };
247
248    let mut launched = choose_terminal_index_prefer_path(terms).is_some_and(|idx| {
249        let (term, args, needs_xfce_command) = terms[idx];
250        try_spawn_terminal(term, args, needs_xfce_command, &cmd_str, &ctx)
251    });
252
253    if !launched {
254        for (term, args, needs_xfce_command) in terms {
255            if command_on_path(term) {
256                launched = try_spawn_terminal(term, args, *needs_xfce_command, &cmd_str, &ctx);
257                if launched {
258                    break;
259                }
260            }
261        }
262    }
263
264    if !launched {
265        let res = Command::new("bash").args(["-lc", &cmd_str]).spawn();
266        if let Err(e) = res {
267            tracing::error!(error = %e, names = %names_str, "failed to spawn bash to run removal command");
268        } else {
269            tracing::info!(
270                names = %names_str,
271                total = names.len(),
272                dry_run = dry_run,
273                mode = ?cascade_mode,
274                "launched bash for removal"
275            );
276        }
277    }
278}
279
280#[cfg(target_os = "windows")]
281/// What: Present a placeholder removal message on Windows where pacman is unavailable.
282///
283/// Input:
284/// - `names`: Packages the user requested to remove.
285/// - `dry_run`: When `true`, uses `PowerShell` to simulate the removal operation.
286/// - `cascade_mode`: Removal mode used for display consistency.
287///
288/// Output:
289/// - Launches a detached `PowerShell` window (if available) for dry-run simulation, or `cmd` window otherwise.
290///
291/// Details:
292/// - When `dry_run` is true and `PowerShell` is available, uses `PowerShell` to simulate the removal with Write-Host.
293/// - Mirrors Unix logging by emitting an info trace, but performs no package operations.
294/// - During tests, this is a no-op to avoid opening real terminal windows.
295#[allow(unused_variables, clippy::missing_const_for_fn)]
296pub fn spawn_remove_all(names: &[String], dry_run: bool, cascade_mode: CascadeMode) {
297    #[cfg(not(test))]
298    {
299        let mut names = names.to_vec();
300        if names.is_empty() {
301            names.push("nothing".into());
302        }
303        let names_str = names.join(" ");
304        let msg = if dry_run {
305            format!("DRY RUN: Would remove packages: {names_str}")
306        } else {
307            format!("Cannot remove packages on Windows: {names_str}")
308        };
309
310        // Check if this is a dry-run operation
311        if dry_run && super::utils::is_powershell_available() {
312            // Use PowerShell to simulate the operation
313            let escaped_msg = msg.replace('\'', "''");
314            let powershell_cmd = format!(
315                "Write-Host '{escaped_msg}' -ForegroundColor Yellow; Write-Host ''; Write-Host 'Press any key to close...'; $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')"
316            );
317            let _ = std::process::Command::new("powershell.exe")
318                .args(["-NoProfile", "-Command", &powershell_cmd])
319                .spawn();
320        } else {
321            let _ = std::process::Command::new("cmd")
322                .args([
323                    "/C",
324                    "start",
325                    "Pacsea Remove",
326                    "cmd",
327                    "/K",
328                    &format!("echo {msg}"),
329                ])
330                .spawn();
331        }
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    #[test]
338    #[cfg(unix)]
339    /// What: Verify the removal helper prefers gnome-terminal and passes the expected dash handling.
340    ///
341    /// Inputs:
342    /// - Fake `gnome-terminal` script injected into `PATH`.
343    /// - `spawn_remove_all` invoked in dry-run cascade mode with two package names.
344    ///
345    /// Output:
346    /// - Captured invocation arguments start with `--`, `bash`, `-lc` to ensure safe command parsing.
347    ///
348    /// Details:
349    /// - Redirects `PACSEA_TEST_OUT` so the shim terminal records arguments, then restores environment variables.
350    fn remove_all_uses_gnome_terminal_double_dash() {
351        use std::fs;
352        use std::os::unix::fs::PermissionsExt;
353        use std::path::PathBuf;
354
355        let mut dir: PathBuf = std::env::temp_dir();
356        dir.push(format!(
357            "pacsea_test_remove_gnome_{}_{}",
358            std::process::id(),
359            std::time::SystemTime::now()
360                .duration_since(std::time::UNIX_EPOCH)
361                .expect("System time is before UNIX epoch")
362                .as_nanos()
363        ));
364        let _ = fs::create_dir_all(&dir);
365        let mut out_path = dir.clone();
366        out_path.push("args.txt");
367        let mut term_path = dir.clone();
368        term_path.push("gnome-terminal");
369        let script = "#!/bin/sh\n: > \"$PACSEA_TEST_OUT\"\nfor a in \"$@\"; do printf '%s\n' \"$a\" >> \"$PACSEA_TEST_OUT\"; done\n";
370        fs::write(&term_path, script.as_bytes()).expect("failed to write test terminal script");
371        let mut perms = fs::metadata(&term_path)
372            .expect("failed to read test terminal script metadata")
373            .permissions();
374        perms.set_mode(0o755);
375        fs::set_permissions(&term_path, perms)
376            .expect("failed to set test terminal script permissions");
377
378        let orig_path = std::env::var_os("PATH");
379        unsafe {
380            std::env::set_var("PATH", dir.display().to_string());
381            std::env::set_var("PACSEA_TEST_OUT", out_path.display().to_string());
382        }
383
384        let names = vec!["ripgrep".to_string(), "fd".to_string()];
385        super::spawn_remove_all(
386            &names,
387            true,
388            crate::state::modal::CascadeMode::CascadeWithConfigs,
389        );
390        std::thread::sleep(std::time::Duration::from_millis(50));
391
392        let body = fs::read_to_string(&out_path).expect("fake terminal args file written");
393        let lines: Vec<&str> = body.lines().collect();
394        assert!(lines.len() >= 3, "expected at least 3 args, got: {body}");
395        assert_eq!(lines[0], "--");
396        assert_eq!(lines[1], "bash");
397        assert_eq!(lines[2], "-lc");
398
399        unsafe {
400            if let Some(v) = orig_path {
401                std::env::set_var("PATH", v);
402            } else {
403                std::env::remove_var("PATH");
404            }
405            std::env::remove_var("PACSEA_TEST_OUT");
406        }
407    }
408}