Skip to main content

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 tool = match crate::logic::privilege::active_tool() {
188        Ok(t) => t,
189        Err(err) => {
190            tracing::error!(error = %err, "privilege tool resolution failed for removal");
191            return;
192        }
193    };
194    let base = format!("pacman {flag} --noconfirm {}", names.join(" "));
195    let cmd_str = if dry_run {
196        let cmd = format!(
197            "{}{hold_tail}",
198            crate::logic::privilege::build_privilege_command(tool, &base)
199        );
200        let quoted = shell_single_quote(&cmd);
201        format!("echo DRY RUN: {quoted}")
202    } else {
203        format!(
204            "{}{hold_tail}",
205            crate::logic::privilege::build_privilege_command(tool, &base)
206        )
207    };
208
209    let terms_gnome_first: &[(&str, &[&str], bool)] = &[
210        ("gnome-terminal", &["--", "bash", "-lc"], false),
211        ("gnome-console", &["--", "bash", "-lc"], false),
212        ("kgx", &["--", "bash", "-lc"], false),
213        ("alacritty", &["-e", "bash", "-lc"], false),
214        ("ghostty", &["-e", "bash", "-lc"], false),
215        ("kitty", &["bash", "-lc"], false),
216        ("konsole", &["-e", "bash", "-lc"], false),
217        ("xterm", &["-hold", "-e", "bash", "-lc"], false),
218        ("xfce4-terminal", &[], true),
219        ("tilix", &["--", "bash", "-lc"], false),
220        ("mate-terminal", &["--", "bash", "-lc"], false),
221    ];
222    let terms_default: &[(&str, &[&str], bool)] = &[
223        ("alacritty", &["-e", "bash", "-lc"], false),
224        ("ghostty", &["-e", "bash", "-lc"], false),
225        ("kitty", &["bash", "-lc"], false),
226        ("konsole", &["-e", "bash", "-lc"], false),
227        ("gnome-terminal", &["--", "bash", "-lc"], false),
228        ("gnome-console", &["--", "bash", "-lc"], false),
229        ("kgx", &["--", "bash", "-lc"], false),
230        ("xterm", &["-hold", "-e", "bash", "-lc"], false),
231        ("xfce4-terminal", &[], true),
232        ("tilix", &["--", "bash", "-lc"], false),
233        ("mate-terminal", &["--", "bash", "-lc"], false),
234    ];
235
236    let is_gnome = std::env::var("XDG_CURRENT_DESKTOP")
237        .ok()
238        .is_some_and(|v| v.to_uppercase().contains("GNOME"));
239    let terms = if is_gnome {
240        terms_gnome_first
241    } else {
242        terms_default
243    };
244
245    let ctx = SpawnContext {
246        names_str: &names_str,
247        names_len: names.len(),
248        dry_run,
249        cascade_mode,
250    };
251
252    let mut launched = choose_terminal_index_prefer_path(terms).is_some_and(|idx| {
253        let (term, args, needs_xfce_command) = terms[idx];
254        try_spawn_terminal(term, args, needs_xfce_command, &cmd_str, &ctx)
255    });
256
257    if !launched {
258        for (term, args, needs_xfce_command) in terms {
259            if command_on_path(term) {
260                launched = try_spawn_terminal(term, args, *needs_xfce_command, &cmd_str, &ctx);
261                if launched {
262                    break;
263                }
264            }
265        }
266    }
267
268    if !launched {
269        let res = Command::new("bash").args(["-lc", &cmd_str]).spawn();
270        if let Err(e) = res {
271            tracing::error!(error = %e, names = %names_str, "failed to spawn bash to run removal command");
272        } else {
273            tracing::info!(
274                names = %names_str,
275                total = names.len(),
276                dry_run = dry_run,
277                mode = ?cascade_mode,
278                "launched bash for removal"
279            );
280        }
281    }
282}
283
284#[cfg(target_os = "windows")]
285/// What: Present a placeholder removal message on Windows where pacman is unavailable.
286///
287/// Input:
288/// - `names`: Packages the user requested to remove.
289/// - `dry_run`: When `true`, uses `PowerShell` to simulate the removal operation.
290/// - `cascade_mode`: Removal mode used for display consistency.
291///
292/// Output:
293/// - Launches a detached `PowerShell` window (if available) for dry-run simulation, or `cmd` window otherwise.
294///
295/// Details:
296/// - When `dry_run` is true and `PowerShell` is available, uses `PowerShell` to simulate the removal with Write-Host.
297/// - Mirrors Unix logging by emitting an info trace, but performs no package operations.
298/// - During tests, this is a no-op to avoid opening real terminal windows.
299#[allow(unused_variables, clippy::missing_const_for_fn)]
300pub fn spawn_remove_all(names: &[String], dry_run: bool, cascade_mode: CascadeMode) {
301    #[cfg(not(test))]
302    {
303        let mut names = names.to_vec();
304        if names.is_empty() {
305            names.push("nothing".into());
306        }
307        let names_str = names.join(" ");
308        let msg = if dry_run {
309            format!("DRY RUN: Would remove packages: {names_str}")
310        } else {
311            format!("Cannot remove packages on Windows: {names_str}")
312        };
313
314        // Check if this is a dry-run operation
315        if dry_run && super::utils::is_powershell_available() {
316            // Use PowerShell to simulate the operation
317            let escaped_msg = msg.replace('\'', "''");
318            let powershell_cmd = format!(
319                "Write-Host '{escaped_msg}' -ForegroundColor Yellow; Write-Host ''; Write-Host 'Press any key to close...'; $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')"
320            );
321            let _ = std::process::Command::new("powershell.exe")
322                .args(["-NoProfile", "-Command", &powershell_cmd])
323                .spawn();
324        } else {
325            let _ = std::process::Command::new("cmd")
326                .args([
327                    "/C",
328                    "start",
329                    "Pacsea Remove",
330                    "cmd",
331                    "/K",
332                    &super::utils::cmd_echo_command(&msg),
333                ])
334                .spawn();
335        }
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    #[test]
342    #[cfg(unix)]
343    /// What: Verify the removal helper prefers gnome-terminal and passes the expected dash handling.
344    ///
345    /// Inputs:
346    /// - Fake `gnome-terminal` script injected into `PATH`.
347    /// - `spawn_remove_all` invoked in dry-run cascade mode with two package names.
348    ///
349    /// Output:
350    /// - Captured invocation arguments start with `--`, `bash`, `-lc` to ensure safe command parsing.
351    ///
352    /// Details:
353    /// - Redirects `PACSEA_TEST_OUT` so the shim terminal records arguments, then restores environment variables.
354    fn remove_all_uses_gnome_terminal_double_dash() {
355        use std::fs;
356        use std::os::unix::fs::PermissionsExt;
357        use std::path::PathBuf;
358
359        let mut dir: PathBuf = std::env::temp_dir();
360        dir.push(format!(
361            "pacsea_test_remove_gnome_{}_{}",
362            std::process::id(),
363            std::time::SystemTime::now()
364                .duration_since(std::time::UNIX_EPOCH)
365                .expect("System time is before UNIX epoch")
366                .as_nanos()
367        ));
368        let _ = fs::create_dir_all(&dir);
369        let mut out_path = dir.clone();
370        out_path.push("args.txt");
371        let mut term_path = dir.clone();
372        term_path.push("gnome-terminal");
373        let script = "#!/bin/sh\n: > \"$PACSEA_TEST_OUT\"\nfor a in \"$@\"; do printf '%s\n' \"$a\" >> \"$PACSEA_TEST_OUT\"; done\n";
374        fs::write(&term_path, script.as_bytes()).expect("failed to write test terminal script");
375        let mut perms = fs::metadata(&term_path)
376            .expect("failed to read test terminal script metadata")
377            .permissions();
378        perms.set_mode(0o755);
379        fs::set_permissions(&term_path, perms)
380            .expect("failed to set test terminal script permissions");
381
382        let orig_path = std::env::var_os("PATH");
383        unsafe {
384            std::env::set_var("PATH", dir.display().to_string());
385            std::env::set_var("PACSEA_TEST_OUT", out_path.display().to_string());
386        }
387
388        let names = vec!["ripgrep".to_string(), "fd".to_string()];
389        super::spawn_remove_all(
390            &names,
391            true,
392            crate::state::modal::CascadeMode::CascadeWithConfigs,
393        );
394        std::thread::sleep(std::time::Duration::from_millis(50));
395
396        let body = fs::read_to_string(&out_path).expect("fake terminal args file written");
397        let lines: Vec<&str> = body.lines().collect();
398        assert!(lines.len() >= 3, "expected at least 3 args, got: {body}");
399        assert_eq!(lines[0], "--");
400        assert_eq!(lines[1], "bash");
401        assert_eq!(lines[2], "-lc");
402
403        unsafe {
404            if let Some(v) = orig_path {
405                std::env::set_var("PATH", v);
406            } else {
407                std::env::remove_var("PATH");
408            }
409            std::env::remove_var("PACSEA_TEST_OUT");
410        }
411    }
412}