pacsea/install/
executor.rs

1//! PTY-based command executor for in-TUI execution.
2
3use crate::state::{PackageItem, modal::CascadeMode};
4
5/// What: Request types for command execution.
6///
7/// Inputs:
8/// - Various operation types (Install, Remove, etc.) with their parameters.
9///
10/// Output:
11/// - Sent to executor worker to trigger command execution.
12///
13/// Details:
14/// - Each variant contains all necessary information to build and execute the command.
15#[derive(Debug, Clone)]
16pub enum ExecutorRequest {
17    /// Install packages.
18    Install {
19        /// Packages to install.
20        items: Vec<PackageItem>,
21        /// Optional sudo password for official packages.
22        password: Option<String>,
23        /// Whether to run in dry-run mode.
24        dry_run: bool,
25    },
26    /// Remove packages.
27    Remove {
28        /// Package names to remove.
29        names: Vec<String>,
30        /// Optional sudo password.
31        password: Option<String>,
32        /// Cascade removal mode.
33        cascade: CascadeMode,
34        /// Whether to run in dry-run mode.
35        dry_run: bool,
36    },
37    /// Downgrade packages.
38    Downgrade {
39        /// Package names to downgrade.
40        names: Vec<String>,
41        /// Optional sudo password.
42        password: Option<String>,
43        /// Whether to run in dry-run mode.
44        dry_run: bool,
45    },
46    /// Custom command execution (for special cases like paru/yay installation).
47    CustomCommand {
48        /// Command string to execute.
49        command: String,
50        /// Optional sudo password for commands that need sudo (e.g., makepkg -si).
51        password: Option<String>,
52        /// Whether to run in dry-run mode.
53        dry_run: bool,
54    },
55    /// System update (mirrors, pacman, AUR, cache).
56    Update {
57        /// Commands to execute in sequence.
58        commands: Vec<String>,
59        /// Optional sudo password for commands that need sudo.
60        password: Option<String>,
61        /// Whether to run in dry-run mode.
62        dry_run: bool,
63    },
64    /// Security scan for AUR package (excluding aur-sleuth).
65    Scan {
66        /// Package name to scan.
67        package: String,
68        /// Scan configuration flags.
69        do_clamav: bool,
70        /// Trivy scan flag.
71        do_trivy: bool,
72        /// Semgrep scan flag.
73        do_semgrep: bool,
74        /// `ShellCheck` scan flag.
75        do_shellcheck: bool,
76        /// `VirusTotal` scan flag.
77        do_virustotal: bool,
78        /// Custom pattern scan flag.
79        do_custom: bool,
80        /// Whether to run in dry-run mode.
81        dry_run: bool,
82    },
83}
84
85/// What: Output messages from command execution.
86///
87/// Inputs:
88/// - Generated by executor worker during command execution.
89///
90/// Output:
91/// - Sent to main event loop for display in `PreflightExec` modal.
92///
93/// Details:
94/// - Line messages contain output from the `PTY`, Finished indicates completion.
95#[derive(Debug, Clone)]
96pub enum ExecutorOutput {
97    /// Output line from command execution.
98    Line(String),
99    /// Replace the last line (used for progress bars with carriage return).
100    ReplaceLastLine(String),
101    /// Command execution finished.
102    Finished {
103        /// Whether execution succeeded.
104        success: bool,
105        /// Exit code if available.
106        exit_code: Option<i32>,
107        /// Name of the failed command (if execution failed and this is an update operation).
108        failed_command: Option<String>,
109    },
110    /// Error occurred during execution.
111    Error(String),
112}
113
114/// What: Build install command string without hold tail for `PTY` execution.
115///
116/// Inputs:
117/// - `items`: Packages to install.
118/// - `_password`: Optional sudo password (unused - password is written to PTY stdin when sudo prompts).
119/// - `dry_run`: Whether to run in dry-run mode.
120///
121/// Output:
122/// - Command string ready for `PTY` execution (no hold tail).
123///
124/// Details:
125/// - Groups official and `AUR` packages separately.
126/// - Uses `--noconfirm` for non-interactive execution.
127/// - Always uses `sudo -S` for official packages (password written to PTY stdin when sudo prompts).
128/// - Removes hold tail since we're not spawning a terminal.
129#[must_use]
130pub fn build_install_command_for_executor(
131    items: &[PackageItem],
132    password: Option<&str>,
133    dry_run: bool,
134) -> String {
135    use super::command::aur_install_body;
136    use super::utils::shell_single_quote;
137    use crate::state::Source;
138
139    let mut official: Vec<String> = Vec::new();
140    let mut aur: Vec<String> = Vec::new();
141
142    for item in items {
143        match item.source {
144            Source::Official { .. } => official.push(item.name.clone()),
145            Source::Aur => aur.push(item.name.clone()),
146        }
147    }
148
149    if dry_run {
150        if !aur.is_empty() {
151            let all: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
152            // Check if any packages are already installed (reinstall scenario)
153            // Use comprehensive check that includes packages provided by installed packages
154            let installed_set = crate::logic::deps::get_installed_packages();
155            let provided_set = crate::logic::deps::get_provided_packages(&installed_set);
156            let has_reinstall = items.iter().any(|item| {
157                crate::logic::deps::is_package_installed_or_provided(
158                    &item.name,
159                    &installed_set,
160                    &provided_set,
161                )
162            });
163            let flags = if has_reinstall {
164                "--noconfirm"
165            } else {
166                "--needed --noconfirm"
167            };
168            let cmd = format!(
169                "(paru -S {flags} {n} || yay -S {flags} {n})",
170                n = all.join(" "),
171                flags = flags
172            );
173            let quoted = shell_single_quote(&cmd);
174            format!("echo DRY RUN: {quoted}")
175        } else if !official.is_empty() {
176            // Check if any packages are already installed (reinstall scenario)
177            // Use comprehensive check that includes packages provided by installed packages
178            let installed_set = crate::logic::deps::get_installed_packages();
179            let provided_set = crate::logic::deps::get_provided_packages(&installed_set);
180            let has_reinstall = official.iter().any(|name| {
181                crate::logic::deps::is_package_installed_or_provided(
182                    name,
183                    &installed_set,
184                    &provided_set,
185                )
186            });
187            let flags = if has_reinstall {
188                "--noconfirm"
189            } else {
190                "--needed --noconfirm"
191            };
192            let cmd = format!(
193                "sudo pacman -S {flags} {n}",
194                n = official.join(" "),
195                flags = flags
196            );
197            let quoted = shell_single_quote(&cmd);
198            format!("echo DRY RUN: {quoted}")
199        } else {
200            "echo DRY RUN: nothing to install".to_string()
201        }
202    } else if !aur.is_empty() {
203        let all: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
204        let n = all.join(" ");
205        // Check if any packages are already installed (reinstall scenario)
206        // Use comprehensive check that includes packages provided by installed packages
207        let installed_set = crate::logic::deps::get_installed_packages();
208        let provided_set = crate::logic::deps::get_provided_packages(&installed_set);
209        let has_reinstall = items.iter().any(|item| {
210            crate::logic::deps::is_package_installed_or_provided(
211                &item.name,
212                &installed_set,
213                &provided_set,
214            )
215        });
216        let flags = if has_reinstall {
217            "-S --noconfirm"
218        } else {
219            "-S --needed --noconfirm"
220        };
221        aur_install_body(flags, &n)
222    } else if !official.is_empty() {
223        // Check if any packages are already installed (reinstall scenario)
224        // Use comprehensive check that includes packages provided by installed packages
225        let installed_set = crate::logic::deps::get_installed_packages();
226        let provided_set = crate::logic::deps::get_provided_packages(&installed_set);
227        let has_reinstall = official.iter().any(|name| {
228            crate::logic::deps::is_package_installed_or_provided(
229                name,
230                &installed_set,
231                &provided_set,
232            )
233        });
234        let flags = if has_reinstall {
235            "--noconfirm"
236        } else {
237            "--needed --noconfirm"
238        };
239        // Sync database first (pacman -Sy) to ensure latest versions are available,
240        // then install the packages
241        let install_cmd = format!("pacman -S {flags} {}", official.join(" "));
242        // Use printf to pipe password to sudo -S (more reliable than echo)
243        password.map_or_else(
244            || format!("sudo pacman -Sy && sudo {install_cmd}"),
245            |pass| {
246                let escaped = shell_single_quote(pass);
247                // Sync first, then install - use single password for both
248                format!("printf '%s\\n' {escaped} | sudo -S pacman -Sy && printf '%s\\n' {escaped} | sudo -S {install_cmd}")
249            },
250        )
251    } else {
252        "echo nothing to install".to_string()
253    }
254}
255
256/// What: Build remove command string without hold tail for `PTY` execution.
257///
258/// Inputs:
259/// - `names`: Package names to remove.
260/// - `password`: Optional sudo password (password is written to PTY stdin when sudo prompts).
261/// - `cascade`: Cascade removal mode.
262/// - `dry_run`: Whether to run in dry-run mode.
263///
264/// Output:
265/// - Command string ready for `PTY` execution (no hold tail).
266///
267/// Details:
268/// - Uses `-R`, `-Rs`, or `-Rns` based on cascade mode.
269/// - Uses `--noconfirm` for non-interactive execution.
270/// - Always uses `sudo -S` for remove operations (password written to PTY stdin when sudo prompts).
271/// - Removes hold tail since we're not spawning a terminal.
272#[must_use]
273pub fn build_remove_command_for_executor(
274    names: &[String],
275    password: Option<&str>,
276    cascade: crate::state::modal::CascadeMode,
277    dry_run: bool,
278) -> String {
279    use super::utils::shell_single_quote;
280
281    if names.is_empty() {
282        return if dry_run {
283            "echo DRY RUN: nothing to remove".to_string()
284        } else {
285            "echo nothing to remove".to_string()
286        };
287    }
288
289    let flag = cascade.flag();
290    let names_str = names.join(" ");
291
292    if dry_run {
293        let cmd = format!("sudo pacman {flag} --noconfirm {names_str}");
294        let quoted = shell_single_quote(&cmd);
295        format!("echo DRY RUN: {quoted}")
296    } else {
297        let base_cmd = format!("pacman {flag} --noconfirm {names_str}");
298        // Use printf to pipe password to sudo -S (more reliable than echo)
299        password.map_or_else(
300            || format!("sudo {base_cmd}"),
301            |pass| {
302                let escaped = shell_single_quote(pass);
303                format!("printf '%s\\n' {escaped} | sudo -S {base_cmd}")
304            },
305        )
306    }
307}
308
309/// What: Build downgrade command string without hold tail for `PTY` execution.
310///
311/// Inputs:
312/// - `names`: Package names to downgrade.
313/// - `_password`: Optional sudo password (unused - password is written to PTY stdin when sudo prompts).
314/// - `dry_run`: Whether to run in dry-run mode.
315///
316/// Output:
317/// - Command string ready for `PTY` execution (no hold tail).
318///
319/// Details:
320/// - Uses the `downgrade` tool to downgrade packages.
321/// - Checks if `downgrade` tool is available before executing.
322/// - Password is written to PTY stdin when sudo prompts, so we don't need to pipe it here.
323/// - Removes hold tail since we're not spawning a terminal.
324#[must_use]
325pub fn build_downgrade_command_for_executor(
326    names: &[String],
327    _password: Option<&str>,
328    dry_run: bool,
329) -> String {
330    use super::utils::shell_single_quote;
331    if names.is_empty() {
332        return if dry_run {
333            "echo DRY RUN: nothing to downgrade".to_string()
334        } else {
335            "echo nothing to downgrade".to_string()
336        };
337    }
338
339    let names_str = names.join(" ");
340
341    if dry_run {
342        let cmd = format!("sudo downgrade {names_str}");
343        let quoted = shell_single_quote(&cmd);
344        format!("echo DRY RUN: {quoted}")
345    } else {
346        // Check if downgrade tool is available, then execute
347        // Note: The check uses sudo but password will be written to PTY stdin when sudo prompts
348        let base_cmd = format!(
349            "if (command -v downgrade >/dev/null 2>&1) || sudo pacman -Qi downgrade >/dev/null 2>&1; then sudo downgrade {names_str}; else echo 'downgrade tool not found. Install \"downgrade\" package.'; fi"
350        );
351        // Password is written to PTY stdin when sudo prompts, so we don't need to pipe it here
352        // Just return the command as-is
353        base_cmd
354    }
355}
356
357/// What: Build system update command string by chaining multiple commands.
358///
359/// Inputs:
360/// - `commands`: List of commands to execute in sequence.
361/// - `password`: Optional sudo password for commands that need sudo.
362/// - `dry_run`: Whether to run in dry-run mode.
363///
364/// Output:
365/// - Command string ready for `PTY` execution (commands chained with `&&`).
366///
367/// Details:
368/// - Chains commands with `&&` so execution stops on first failure.
369/// - For commands starting with `sudo`, pipes password if provided.
370/// - In dry-run mode, wraps each command in `echo DRY RUN:`.
371/// - Removes hold tail since we're not spawning a terminal.
372#[must_use]
373pub fn build_update_command_for_executor(
374    commands: &[String],
375    password: Option<&str>,
376    dry_run: bool,
377) -> String {
378    use super::utils::shell_single_quote;
379
380    if commands.is_empty() {
381        return if dry_run {
382            "echo DRY RUN: nothing to update".to_string()
383        } else {
384            "echo nothing to update".to_string()
385        };
386    }
387
388    let processed_commands: Vec<String> = if dry_run {
389        // Check if we should simulate failure for testing (first command only, if it's pacman)
390        let simulate_failure = std::env::var("PACSEA_TEST_SIMULATE_PACMAN_FAILURE").is_ok()
391            && !commands.is_empty()
392            && commands[0].contains("pacman");
393
394        if simulate_failure {
395            tracing::info!(
396                "[DRY-RUN] Simulating pacman failure for testing - first command will fail with exit code 1"
397            );
398        }
399
400        commands
401            .iter()
402            .enumerate()
403            .map(|(idx, c)| {
404                // Properly quote the command to avoid syntax errors with complex shell constructs
405                let quoted = shell_single_quote(c);
406                if simulate_failure && idx == 0 {
407                    // Simulate pacman failure for testing confirmation popup
408                    // Use false to ensure the command fails with exit code 1
409                    // The && will prevent subsequent commands from running
410                    format!("echo DRY RUN: {quoted} && false")
411                } else {
412                    format!("echo DRY RUN: {quoted}")
413                }
414            })
415            .collect()
416    } else {
417        commands
418            .iter()
419            .map(|cmd| {
420                // Check if command needs sudo and has password
421                password.map_or_else(
422                    || cmd.clone(),
423                    |pass| {
424                        if cmd.starts_with("sudo ") {
425                            // Extract the command after "sudo "
426                            let base_cmd = cmd.strip_prefix("sudo ").unwrap_or(cmd);
427                            let escaped = shell_single_quote(pass);
428                            format!("printf '%s\\n' {escaped} | sudo -S {base_cmd}")
429                        } else {
430                            // Command doesn't need password or already has it handled
431                            cmd.clone()
432                        }
433                    },
434                )
435            })
436            .collect()
437    };
438
439    let joined = processed_commands.join(" && ");
440
441    // If password is provided, cache sudo credentials first so that:
442    // - Commands starting with "sudo " get password via pipe (handled above)
443    // - Commands that don't contain "sudo" but invoke it internally (e.g. paru/yay -Sua
444    //   which call sudo for pacman) get a cached session and don't prompt in the PTY
445    if let Some(pass) = password {
446        let escaped = shell_single_quote(pass);
447        // Cache sudo credentials first using sudo -v, then run the commands
448        // Using `;` ensures commands run even if credential caching has issues
449        return format!("printf '%s\\n' {escaped} | sudo -S -v 2>/dev/null ; {joined}");
450    }
451
452    joined
453}
454
455/// What: Build scan command string for `PTY` execution (excluding aur-sleuth).
456///
457/// Inputs:
458/// - `package`: Package name to scan.
459/// - `do_clamav`/`do_trivy`/`do_semgrep`/`do_shellcheck`/`do_virustotal`/`do_custom`: Scan configuration flags.
460/// - `dry_run`: Whether to run in dry-run mode.
461///
462/// Output:
463/// - Command string ready for `PTY` execution (no hold tail, excludes aur-sleuth).
464///
465/// Details:
466/// - Builds scan pipeline commands excluding aur-sleuth (which runs separately in terminal).
467/// - Sets environment variables for scan configuration.
468/// - Removes hold tail since we're not spawning a terminal.
469#[cfg(not(target_os = "windows"))]
470#[must_use]
471#[allow(clippy::fn_params_excessive_bools, clippy::too_many_arguments)]
472pub fn build_scan_command_for_executor(
473    package: &str,
474    do_clamav: bool,
475    do_trivy: bool,
476    do_semgrep: bool,
477    do_shellcheck: bool,
478    do_virustotal: bool,
479    do_custom: bool,
480    dry_run: bool,
481) -> String {
482    use super::utils::shell_single_quote;
483    use crate::install::scan::pkg::build_scan_cmds_for_pkg_without_sleuth;
484
485    // Prepend environment exports so subsequent steps honor the selection
486    let mut cmds: Vec<String> = Vec::new();
487    cmds.push(format!(
488        "export PACSEA_SCAN_DO_CLAMAV={}",
489        if do_clamav { "1" } else { "0" }
490    ));
491    cmds.push(format!(
492        "export PACSEA_SCAN_DO_TRIVY={}",
493        if do_trivy { "1" } else { "0" }
494    ));
495    cmds.push(format!(
496        "export PACSEA_SCAN_DO_SEMGREP={}",
497        if do_semgrep { "1" } else { "0" }
498    ));
499    cmds.push(format!(
500        "export PACSEA_SCAN_DO_SHELLCHECK={}",
501        if do_shellcheck { "1" } else { "0" }
502    ));
503    cmds.push(format!(
504        "export PACSEA_SCAN_DO_VIRUSTOTAL={}",
505        if do_virustotal { "1" } else { "0" }
506    ));
507    cmds.push(format!(
508        "export PACSEA_SCAN_DO_CUSTOM={}",
509        if do_custom { "1" } else { "0" }
510    ));
511    // Export default pattern sets
512    cmds.push("export PACSEA_PATTERNS_CRIT='/dev/(tcp|udp)/|bash -i *>& *[^ ]*/dev/(tcp|udp)/[0-9]+|exec [0-9]{2,}<>/dev/(tcp|udp)/|rm -rf[[:space:]]+/|dd if=/dev/zero of=/dev/sd[a-z]|[>]{1,2}[[:space:]]*/dev/sd[a-z]|: *\\(\\) *\\{ *: *\\| *: *& *\\};:|/etc/sudoers([[:space:]>]|$)|echo .*[>]{2}.*(/etc/sudoers|/root/.ssh/authorized_keys)|/etc/ld\\.so\\.preload|LD_PRELOAD=|authorized_keys.*[>]{2}|ssh-rsa [A-Za-z0-9+/=]+.*[>]{2}.*authorized_keys|curl .*(169\\.254\\.169\\.254)'".to_string());
513    cmds.push("export PACSEA_PATTERNS_HIGH='eval|base64 -d|wget .*(sh|bash|dash|ksh|zsh)([^A-Za-z]|$)|curl .*(sh|bash|dash|ksh|zsh)([^A-Za-z]|$)|sudo[[:space:]]|chattr[[:space:]]|useradd|adduser|groupadd|systemctl|service[[:space:]]|crontab|/etc/cron\\.|[>]{2}.*(\\.bashrc|\\.bash_profile|/etc/profile|\\.zshrc)|cat[[:space:]]+/etc/shadow|cat[[:space:]]+~/.ssh/id_rsa|cat[[:space:]]+~/.bash_history|systemctl stop (auditd|rsyslog)|service (auditd|rsyslog) stop|scp .*@|curl -F|nc[[:space:]].*<|tar -czv?f|zip -r'".to_string());
514    cmds.push("export PACSEA_PATTERNS_MEDIUM='whoami|uname -a|hostname|id|groups|nmap|netstat -anp|ss -anp|ifconfig|ip addr|arp -a|grep -ri .*secret|find .*-name.*(password|\\.key)|env[[:space:]]*\\|[[:space:]]*grep -i pass|wget https?://|curl https?://'".to_string());
515    cmds.push("export PACSEA_PATTERNS_LOW='http_proxy=|https_proxy=|ALL_PROXY=|yes[[:space:]]+> */dev/null *&|ulimit -n [0-9]{5,}'".to_string());
516
517    // Append the scan pipeline commands (excluding sleuth)
518    cmds.extend(build_scan_cmds_for_pkg_without_sleuth(package));
519
520    let full_cmd = cmds.join(" && ");
521
522    if dry_run {
523        let quoted = shell_single_quote(&full_cmd);
524        format!("echo DRY RUN: {quoted}")
525    } else {
526        full_cmd
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use crate::state::Source;
534
535    /// What: Create a test package item with specified source.
536    ///
537    /// Inputs:
538    /// - `name`: Package name
539    /// - `source`: Package source (Official or AUR)
540    ///
541    /// Output:
542    /// - `PackageItem` ready for testing
543    ///
544    /// Details:
545    /// - Helper to create test packages with consistent structure
546    fn create_test_package(name: &str, source: Source) -> PackageItem {
547        PackageItem {
548            name: name.into(),
549            version: "1.0.0".into(),
550            description: String::new(),
551            source,
552            popularity: None,
553            out_of_date: None,
554            orphaned: false,
555        }
556    }
557
558    #[test]
559    /// What: Verify executor command builder creates correct commands without hold tail.
560    ///
561    /// Inputs:
562    /// - Official and AUR packages.
563    /// - Optional password.
564    /// - Dry-run flag.
565    ///
566    /// Output:
567    /// - Commands without hold tail, suitable for PTY execution.
568    ///
569    /// Details:
570    /// - Ensures commands are properly formatted and don't include terminal hold prompts.
571    fn executor_build_install_command_variants() {
572        let official_pkg = create_test_package(
573            "ripgrep",
574            Source::Official {
575                repo: "extra".into(),
576                arch: "x86_64".into(),
577            },
578        );
579
580        let aur_pkg = create_test_package("yay-bin", Source::Aur);
581
582        // Official package without password
583        // Check if package is installed to determine expected flags
584        let installed_set = crate::logic::deps::get_installed_packages();
585        let provided_set = crate::logic::deps::get_provided_packages(&installed_set);
586        let is_installed = crate::logic::deps::is_package_installed_or_provided(
587            "ripgrep",
588            &installed_set,
589            &provided_set,
590        );
591        let cmd1 =
592            build_install_command_for_executor(std::slice::from_ref(&official_pkg), None, false);
593        if is_installed {
594            // If installed, should use only --noconfirm
595            assert!(cmd1.contains("sudo pacman -S --noconfirm ripgrep"));
596            assert!(!cmd1.contains("--needed"));
597        } else {
598            // If not installed, should use --needed --noconfirm
599            assert!(cmd1.contains("sudo pacman -S --needed --noconfirm ripgrep"));
600        }
601        assert!(!cmd1.contains("Press any key to close"));
602
603        // Official package with password
604        let cmd2 = build_install_command_for_executor(
605            std::slice::from_ref(&official_pkg),
606            Some("pass"),
607            false,
608        );
609        assert!(cmd2.contains("printf "));
610        if is_installed {
611            // If installed, should use only --noconfirm
612            assert!(cmd2.contains("sudo -S pacman -S --noconfirm ripgrep"));
613            assert!(!cmd2.contains("--needed"));
614        } else {
615            // If not installed, should use --needed --noconfirm
616            assert!(cmd2.contains("sudo -S pacman -S --needed --noconfirm ripgrep"));
617        }
618
619        // AUR package
620        let cmd3 = build_install_command_for_executor(std::slice::from_ref(&aur_pkg), None, false);
621        assert!(cmd3.contains("command -v paru"));
622        assert!(!cmd3.contains("Press any key to close"));
623
624        // Dry run
625        let cmd4 = build_install_command_for_executor(&[official_pkg], None, true);
626        assert!(cmd4.starts_with("echo DRY RUN:"));
627    }
628
629    #[test]
630    /// What: Verify command builder handles mixed official and AUR packages.
631    ///
632    /// Inputs:
633    /// - Mixed list of official and AUR packages.
634    ///
635    /// Output:
636    /// - Command that installs all packages using appropriate tool.
637    ///
638    /// Details:
639    /// - When AUR packages are present, command should use AUR helper for all packages.
640    fn executor_build_mixed_packages() {
641        let official_pkg = create_test_package(
642            "ripgrep",
643            Source::Official {
644                repo: "extra".into(),
645                arch: "x86_64".into(),
646            },
647        );
648        let aur_pkg = create_test_package("yay-bin", Source::Aur);
649
650        let cmd = build_install_command_for_executor(&[official_pkg, aur_pkg], None, false);
651        // When AUR packages are present, should use AUR helper
652        assert!(cmd.contains("command -v paru") || cmd.contains("command -v yay"));
653    }
654
655    #[test]
656    /// What: Verify command builder handles empty package list.
657    ///
658    /// Inputs:
659    /// - Empty package list.
660    ///
661    /// Output:
662    /// - Command that indicates nothing to install.
663    ///
664    /// Details:
665    /// - Empty list should produce a safe no-op command.
666    fn executor_build_empty_list() {
667        let cmd = build_install_command_for_executor(&[], None, false);
668        assert!(cmd.contains("nothing to install") || cmd.is_empty());
669    }
670
671    #[test]
672    /// What: Verify command builder handles multiple official packages.
673    ///
674    /// Inputs:
675    /// - Multiple official packages.
676    ///
677    /// Output:
678    /// - Command that installs all packages via pacman.
679    ///
680    /// Details:
681    /// - Multiple packages should be space-separated in the command.
682    fn executor_build_multiple_official() {
683        let pkg1 = create_test_package(
684            "ripgrep",
685            Source::Official {
686                repo: "extra".into(),
687                arch: "x86_64".into(),
688            },
689        );
690        let pkg2 = create_test_package(
691            "fd",
692            Source::Official {
693                repo: "extra".into(),
694                arch: "x86_64".into(),
695            },
696        );
697
698        // Check if packages are installed to determine expected flags
699        let installed_set = crate::logic::deps::get_installed_packages();
700        let provided_set = crate::logic::deps::get_provided_packages(&installed_set);
701        let ripgrep_installed = crate::logic::deps::is_package_installed_or_provided(
702            "ripgrep",
703            &installed_set,
704            &provided_set,
705        );
706        let fd_installed = crate::logic::deps::is_package_installed_or_provided(
707            "fd",
708            &installed_set,
709            &provided_set,
710        );
711        let has_reinstall = ripgrep_installed || fd_installed;
712
713        let cmd = build_install_command_for_executor(&[pkg1, pkg2], None, false);
714        assert!(cmd.contains("ripgrep"));
715        assert!(cmd.contains("fd"));
716        if has_reinstall {
717            // If any package is installed, should use only --noconfirm
718            assert!(cmd.contains("pacman -S --noconfirm"));
719            assert!(!cmd.contains("--needed"));
720        } else {
721            // If no packages are installed, should use --needed --noconfirm
722            assert!(cmd.contains("pacman -S --needed --noconfirm"));
723        }
724    }
725
726    #[test]
727    /// What: Verify dry-run mode produces echo commands.
728    ///
729    /// Inputs:
730    /// - Package list with `dry_run=true`.
731    ///
732    /// Output:
733    /// - Command that starts with "echo DRY RUN:".
734    ///
735    /// Details:
736    /// - Dry-run should never execute actual install commands.
737    fn executor_build_dry_run() {
738        let pkg = create_test_package(
739            "ripgrep",
740            Source::Official {
741                repo: "extra".into(),
742                arch: "x86_64".into(),
743            },
744        );
745
746        let cmd = build_install_command_for_executor(&[pkg], None, true);
747        assert!(cmd.starts_with("echo DRY RUN:"));
748        // In dry-run mode, the command is wrapped in echo, so it may contain the original command text
749        // The important thing is that it starts with "echo DRY RUN:" which prevents execution
750    }
751
752    #[test]
753    /// What: Verify password is properly escaped in command.
754    ///
755    /// Inputs:
756    /// - Official package with password containing special characters.
757    ///
758    /// Output:
759    /// - Command with properly escaped password.
760    ///
761    /// Details:
762    /// - Password should be single-quoted to prevent shell injection.
763    fn executor_build_password_escaping() {
764        let pkg = create_test_package(
765            "ripgrep",
766            Source::Official {
767                repo: "extra".into(),
768                arch: "x86_64".into(),
769            },
770        );
771
772        // Password with special characters
773        let password = "pass'word\"with$special";
774        let cmd = build_install_command_for_executor(&[pkg], Some(password), false);
775        assert!(cmd.contains("printf"));
776        assert!(cmd.contains("sudo -S"));
777        // Password should be properly quoted
778        assert!(cmd.contains('\'') || cmd.contains('"'));
779    }
780
781    #[test]
782    /// What: Verify remove command builder creates correct commands without hold tail.
783    ///
784    /// Inputs:
785    /// - Package names, cascade mode, optional password, dry-run flag.
786    ///
787    /// Output:
788    /// - Commands without hold tail, suitable for PTY execution.
789    ///
790    /// Details:
791    /// - Ensures commands are properly formatted and don't include terminal hold prompts.
792    fn executor_build_remove_command_variants() {
793        use crate::state::modal::CascadeMode;
794
795        let names = vec!["test-pkg1".to_string(), "test-pkg2".to_string()];
796
797        // Basic mode without password
798        let cmd1 = build_remove_command_for_executor(&names, None, CascadeMode::Basic, false);
799        assert!(cmd1.contains("sudo pacman -R --noconfirm"));
800        assert!(cmd1.contains("test-pkg1"));
801        assert!(cmd1.contains("test-pkg2"));
802        assert!(!cmd1.contains("Press any key to close"));
803
804        // Cascade mode with password
805        let cmd2 =
806            build_remove_command_for_executor(&names, Some("pass"), CascadeMode::Cascade, false);
807        assert!(cmd2.contains("printf "));
808        assert!(cmd2.contains("sudo -S pacman -Rs --noconfirm"));
809
810        // CascadeWithConfigs mode
811        let cmd3 =
812            build_remove_command_for_executor(&names, None, CascadeMode::CascadeWithConfigs, false);
813        assert!(cmd3.contains("sudo pacman -Rns --noconfirm"));
814
815        // Dry run
816        let cmd4 = build_remove_command_for_executor(&names, None, CascadeMode::Basic, true);
817        assert!(cmd4.starts_with("echo DRY RUN:"));
818        assert!(cmd4.contains("pacman -R --noconfirm"));
819
820        // Empty list
821        let cmd5 = build_remove_command_for_executor(&[], None, CascadeMode::Basic, false);
822        assert_eq!(cmd5, "echo nothing to remove");
823    }
824}