Skip to main content

pacsea/install/
executor.rs

1//! PTY-based command executor for in-TUI execution.
2
3use crate::state::SecureString;
4use crate::state::{PackageItem, modal::CascadeMode};
5
6/// What: Request types for command execution.
7///
8/// Inputs:
9/// - Various operation types (Install, Remove, etc.) with their parameters.
10///
11/// Output:
12/// - Sent to executor worker to trigger command execution.
13///
14/// Details:
15/// - Each variant contains all necessary information to build and execute the command.
16#[derive(Debug, Clone)]
17pub enum ExecutorRequest {
18    /// Install packages.
19    Install {
20        /// Packages to install.
21        items: Vec<PackageItem>,
22        /// Optional sudo password for official packages.
23        password: Option<SecureString>,
24        /// Whether to run in dry-run mode.
25        dry_run: bool,
26    },
27    /// Remove packages.
28    Remove {
29        /// Package names to remove.
30        names: Vec<String>,
31        /// Optional sudo password.
32        password: Option<SecureString>,
33        /// Cascade removal mode.
34        cascade: CascadeMode,
35        /// Whether to run in dry-run mode.
36        dry_run: bool,
37    },
38    /// Downgrade packages.
39    Downgrade {
40        /// Package names to downgrade.
41        names: Vec<String>,
42        /// Optional sudo password.
43        password: Option<SecureString>,
44        /// Whether to run in dry-run mode.
45        dry_run: bool,
46    },
47    /// Custom command execution (for special cases like paru/yay installation).
48    CustomCommand {
49        /// Command string to execute.
50        command: String,
51        /// Optional sudo password for commands that need sudo (e.g., makepkg -si).
52        password: Option<SecureString>,
53        /// Whether to run in dry-run mode.
54        dry_run: bool,
55    },
56    /// System update (mirrors, pacman, AUR, cache).
57    Update {
58        /// Commands to execute in sequence.
59        commands: Vec<String>,
60        /// Optional sudo password for commands that need sudo.
61        password: Option<SecureString>,
62        /// Whether to run in dry-run mode.
63        dry_run: bool,
64    },
65    /// Security scan for AUR package (excluding aur-sleuth).
66    Scan {
67        /// Package name to scan.
68        package: String,
69        /// Scan configuration flags.
70        do_clamav: bool,
71        /// Trivy scan flag.
72        do_trivy: bool,
73        /// Semgrep scan flag.
74        do_semgrep: bool,
75        /// `ShellCheck` scan flag.
76        do_shellcheck: bool,
77        /// `VirusTotal` scan flag.
78        do_virustotal: bool,
79        /// Custom pattern scan flag.
80        do_custom: bool,
81        /// Whether to run in dry-run mode.
82        dry_run: bool,
83    },
84}
85
86/// What: Output messages from command execution.
87///
88/// Inputs:
89/// - Generated by executor worker during command execution.
90///
91/// Output:
92/// - Sent to main event loop for display in `PreflightExec` modal.
93///
94/// Details:
95/// - Line messages contain output from the `PTY`, Finished indicates completion.
96#[derive(Debug, Clone)]
97pub enum ExecutorOutput {
98    /// Output line from command execution.
99    Line(String),
100    /// Replace the last line (used for progress bars with carriage return).
101    ReplaceLastLine(String),
102    /// Command execution finished.
103    Finished {
104        /// Whether execution succeeded.
105        success: bool,
106        /// Exit code if available.
107        exit_code: Option<i32>,
108        /// Name of the failed command (if execution failed and this is an update operation).
109        failed_command: Option<String>,
110    },
111    /// Error occurred during execution.
112    Error(String),
113}
114
115/// What: Build install command string without hold tail for `PTY` execution.
116///
117/// Inputs:
118/// - `items`: Packages to install.
119/// - `_password`: Optional sudo password (unused - password is written to PTY stdin when sudo prompts).
120/// - `dry_run`: Whether to run in dry-run mode.
121///
122/// Output:
123/// - Command string ready for `PTY` execution (no hold tail).
124///
125/// Details:
126/// - Groups official and `AUR` packages separately; mixed installs run `pacman` then `paru`/`yay` with `--aur` on **AUR-only** names.
127/// - Uses `--noconfirm` for non-interactive execution.
128/// - Always uses `sudo -S` for official packages (password written to PTY stdin when sudo prompts).
129/// - Removes hold tail since we're not spawning a terminal.
130///
131/// # Errors
132///
133/// Returns `Err` when the configured privilege tool cannot be resolved for official package paths.
134pub fn build_install_command_for_executor(
135    items: &[PackageItem],
136    password: Option<&str>,
137    dry_run: bool,
138) -> Result<String, String> {
139    use super::command::{aur_install_body, aur_install_helper_flags};
140    use super::utils::{shell_single_quote, validate_package_names};
141    use crate::state::Source;
142
143    let mut official: Vec<String> = Vec::new();
144    let mut aur: Vec<String> = Vec::new();
145
146    for item in items {
147        match item.source {
148            Source::Official { .. } => official.push(item.name.clone()),
149            Source::Aur => aur.push(item.name.clone()),
150        }
151    }
152    validate_package_names(&official, "executor install command (official)")?;
153    validate_package_names(&aur, "executor install command (AUR)")?;
154    let official_quoted: Vec<String> = official
155        .iter()
156        .map(|name| shell_single_quote(name))
157        .collect();
158    let aur_quoted: Vec<String> = aur.iter().map(|name| shell_single_quote(name)).collect();
159
160    let installed_set = crate::logic::deps::get_installed_packages();
161    let provided_set = crate::logic::deps::get_provided_packages(&installed_set);
162
163    let official_has_reinstall = official.iter().any(|name| {
164        crate::logic::deps::is_package_installed_or_provided(name, &installed_set, &provided_set)
165    });
166    let pacman_flags = if official_has_reinstall {
167        "--noconfirm"
168    } else {
169        "--needed --noconfirm"
170    };
171
172    let aur_has_reinstall = aur.iter().any(|name| {
173        crate::logic::deps::is_package_installed_or_provided(name, &installed_set, &provided_set)
174    });
175    let aur_s_flags = aur_install_helper_flags(aur_has_reinstall);
176    let aur_cli_suffix = if aur_has_reinstall {
177        "--noconfirm"
178    } else {
179        "--needed --noconfirm"
180    };
181
182    let aur_names = aur_quoted.join(" ");
183
184    if dry_run {
185        if !aur.is_empty() && !official.is_empty() {
186            let tool = crate::logic::privilege::active_tool()?;
187            let off_cmd = crate::logic::privilege::build_privilege_command(
188                tool,
189                &format!("pacman -S {pacman_flags} {}", official_quoted.join(" ")),
190            );
191            let aur_cmd = format!(
192                "(paru -S --aur {aur_cli_suffix} {aur_names} || yay -S --aur {aur_cli_suffix} {aur_names})",
193            );
194            let combined = format!("{off_cmd} && {aur_cmd}");
195            let quoted = shell_single_quote(&combined);
196            Ok(format!("echo DRY RUN: {quoted}"))
197        } else if !aur.is_empty() {
198            let aur_cmd = format!(
199                "(paru -S --aur {aur_cli_suffix} {aur_names} || yay -S --aur {aur_cli_suffix} {aur_names})",
200            );
201            let quoted = shell_single_quote(&aur_cmd);
202            Ok(format!("echo DRY RUN: {quoted}"))
203        } else if !official.is_empty() {
204            let tool = crate::logic::privilege::active_tool()?;
205            let cmd = crate::logic::privilege::build_privilege_command(
206                tool,
207                &format!("pacman -S {pacman_flags} {}", official_quoted.join(" ")),
208            );
209            let quoted = shell_single_quote(&cmd);
210            Ok(format!("echo DRY RUN: {quoted}"))
211        } else {
212            Ok("echo DRY RUN: nothing to install".to_string())
213        }
214    } else if !aur.is_empty() && !official.is_empty() {
215        let tool = crate::logic::privilege::active_tool()?;
216        let install_cmd = format!("pacman -S {pacman_flags} {}", official_quoted.join(" "));
217        let official_chain = password.map_or_else(
218            || {
219                let sync = crate::logic::privilege::build_privilege_command(tool, "pacman -Sy");
220                let install = crate::logic::privilege::build_privilege_command(tool, &install_cmd);
221                format!("{sync} && {install}")
222            },
223            |pass| {
224                crate::logic::privilege::build_password_pipe(tool, pass, "pacman -Sy").map_or_else(
225                    || {
226                        let sync =
227                            crate::logic::privilege::build_privilege_command(tool, "pacman -Sy");
228                        let install =
229                            crate::logic::privilege::build_privilege_command(tool, &install_cmd);
230                        format!("{sync} && {install}")
231                    },
232                    |sync_pipe| {
233                        let install_pipe =
234                            crate::logic::privilege::build_password_pipe(tool, pass, &install_cmd)
235                                .unwrap_or_else(|| {
236                                    crate::logic::privilege::build_privilege_command(
237                                        tool,
238                                        &install_cmd,
239                                    )
240                                });
241                        format!("{sync_pipe} && {install_pipe}")
242                    },
243                )
244            },
245        );
246        Ok(format!(
247            "{} && {}",
248            official_chain,
249            aur_install_body(aur_s_flags, &aur_names)
250        ))
251    } else if !aur.is_empty() {
252        Ok(aur_install_body(aur_s_flags, &aur_names))
253    } else if !official.is_empty() {
254        let tool = crate::logic::privilege::active_tool()?;
255        let install_cmd = format!("pacman -S {pacman_flags} {}", official_quoted.join(" "));
256        Ok(password.map_or_else(
257            || {
258                let sync = crate::logic::privilege::build_privilege_command(tool, "pacman -Sy");
259                let install = crate::logic::privilege::build_privilege_command(tool, &install_cmd);
260                format!("{sync} && {install}")
261            },
262            |pass| {
263                crate::logic::privilege::build_password_pipe(tool, pass, "pacman -Sy").map_or_else(
264                    || {
265                        let sync =
266                            crate::logic::privilege::build_privilege_command(tool, "pacman -Sy");
267                        let install =
268                            crate::logic::privilege::build_privilege_command(tool, &install_cmd);
269                        format!("{sync} && {install}")
270                    },
271                    |sync_pipe| {
272                        let install_pipe =
273                            crate::logic::privilege::build_password_pipe(tool, pass, &install_cmd)
274                                .unwrap_or_else(|| {
275                                    crate::logic::privilege::build_privilege_command(
276                                        tool,
277                                        &install_cmd,
278                                    )
279                                });
280                        format!("{sync_pipe} && {install_pipe}")
281                    },
282                )
283            },
284        ))
285    } else {
286        Ok("echo nothing to install".to_string())
287    }
288}
289
290/// What: Build remove command string without hold tail for `PTY` execution.
291///
292/// Inputs:
293/// - `names`: Package names to remove.
294/// - `password`: Optional sudo password (password is written to PTY stdin when sudo prompts).
295/// - `cascade`: Cascade removal mode.
296/// - `dry_run`: Whether to run in dry-run mode.
297///
298/// Output:
299/// - Command string ready for `PTY` execution (no hold tail).
300///
301/// Details:
302/// - Uses `-R`, `-Rs`, or `-Rns` based on cascade mode.
303/// - Uses `--noconfirm` for non-interactive execution.
304/// - Always uses `sudo -S` for remove operations (password written to PTY stdin when sudo prompts).
305/// - Removes hold tail since we're not spawning a terminal.
306///
307/// # Errors
308///
309/// Returns `Err` when the configured privilege tool cannot be resolved.
310pub fn build_remove_command_for_executor(
311    names: &[String],
312    password: Option<&str>,
313    cascade: crate::state::modal::CascadeMode,
314    dry_run: bool,
315) -> Result<String, String> {
316    use super::utils::shell_single_quote;
317
318    if names.is_empty() {
319        return Ok(if dry_run {
320            "echo DRY RUN: nothing to remove".to_string()
321        } else {
322            "echo nothing to remove".to_string()
323        });
324    }
325
326    let flag = cascade.flag();
327    let names_str = names.join(" ");
328
329    let tool = crate::logic::privilege::active_tool()?;
330    let base_cmd = format!("pacman {flag} --noconfirm {names_str}");
331
332    if dry_run {
333        let cmd = crate::logic::privilege::build_privilege_command(tool, &base_cmd);
334        let quoted = shell_single_quote(&cmd);
335        Ok(format!("echo DRY RUN: {quoted}"))
336    } else {
337        Ok(password.map_or_else(
338            || crate::logic::privilege::build_privilege_command(tool, &base_cmd),
339            |pass| {
340                crate::logic::privilege::build_password_pipe(tool, pass, &base_cmd).unwrap_or_else(
341                    || crate::logic::privilege::build_privilege_command(tool, &base_cmd),
342                )
343            },
344        ))
345    }
346}
347
348/// What: Build downgrade command string without hold tail for `PTY` execution.
349///
350/// Inputs:
351/// - `names`: Package names to downgrade.
352/// - `_password`: Optional password (unused - downgrade runs interactively in PTY).
353/// - `dry_run`: Whether to run in dry-run mode.
354///
355/// Output:
356/// - Command string ready for `PTY` execution (no hold tail).
357///
358/// Details:
359/// - Uses the `downgrade` tool to downgrade packages.
360/// - Checks if `downgrade` tool is available before executing.
361/// - Read-only package checks use `pacman -Qi` without privilege escalation.
362/// - Removes hold tail since we're not spawning a terminal.
363///
364/// # Errors
365///
366/// Returns `Err` when the configured privilege tool cannot be resolved.
367pub fn build_downgrade_command_for_executor(
368    names: &[String],
369    _password: Option<&str>,
370    dry_run: bool,
371) -> Result<String, String> {
372    use super::utils::shell_single_quote;
373    if names.is_empty() {
374        return Ok(if dry_run {
375            "echo DRY RUN: nothing to downgrade".to_string()
376        } else {
377            "echo nothing to downgrade".to_string()
378        });
379    }
380
381    let names_str = names.join(" ");
382
383    let tool = crate::logic::privilege::active_tool()?;
384    let bin = tool.binary_name();
385
386    if dry_run {
387        let cmd = crate::logic::privilege::build_privilege_command(
388            tool,
389            &format!("downgrade {names_str}"),
390        );
391        let quoted = shell_single_quote(&cmd);
392        Ok(format!("echo DRY RUN: {quoted}"))
393    } else {
394        Ok(format!(
395            "if (command -v downgrade >/dev/null 2>&1) || pacman -Qi downgrade >/dev/null 2>&1; then {bin} downgrade {names_str}; else echo 'downgrade tool not found. Install \"downgrade\" package.'; fi"
396        ))
397    }
398}
399
400/// What: Build system update command string by chaining multiple commands.
401///
402/// Inputs:
403/// - `commands`: List of commands to execute in sequence.
404/// - `password`: Optional sudo password for commands that need sudo.
405/// - `dry_run`: Whether to run in dry-run mode.
406///
407/// Output:
408/// - Command string ready for `PTY` execution (commands chained with `&&`).
409///
410/// Details:
411/// - Chains commands with `&&` so execution stops on first failure.
412/// - For commands starting with `sudo`, pipes password if provided.
413/// - In dry-run mode, wraps each command in `echo DRY RUN:`.
414/// - Removes hold tail since we're not spawning a terminal.
415///
416/// # Errors
417///
418/// Returns `Err` when the configured privilege tool cannot be resolved for non-dry-run paths
419/// or credential warm-up.
420pub fn build_update_command_for_executor(
421    commands: &[String],
422    password: Option<&str>,
423    dry_run: bool,
424) -> Result<String, String> {
425    use super::utils::shell_single_quote;
426
427    if commands.is_empty() {
428        return Ok(if dry_run {
429            "echo DRY RUN: nothing to update".to_string()
430        } else {
431            "echo nothing to update".to_string()
432        });
433    }
434
435    let processed_commands: Vec<String> = if dry_run {
436        // Check if we should simulate failure for testing (first command only, if it's pacman)
437        let simulate_failure = std::env::var("PACSEA_TEST_SIMULATE_PACMAN_FAILURE").is_ok()
438            && !commands.is_empty()
439            && commands[0].contains("pacman");
440
441        if simulate_failure {
442            tracing::info!(
443                "[DRY-RUN] Simulating pacman failure for testing - first command will fail with exit code 1"
444            );
445        }
446
447        commands
448            .iter()
449            .enumerate()
450            .map(|(idx, c)| {
451                // Properly quote the command to avoid syntax errors with complex shell constructs
452                let quoted = shell_single_quote(c);
453                if simulate_failure && idx == 0 {
454                    // Simulate pacman failure for testing confirmation popup
455                    // Use false to ensure the command fails with exit code 1
456                    // The && will prevent subsequent commands from running
457                    format!("echo DRY RUN: {quoted} && false")
458                } else {
459                    format!("echo DRY RUN: {quoted}")
460                }
461            })
462            .collect()
463    } else {
464        let tool = crate::logic::privilege::active_tool()?;
465        let prefix = format!("{} ", tool.binary_name());
466        commands
467            .iter()
468            .map(|cmd| {
469                password.map_or_else(
470                    || cmd.clone(),
471                    |pass| {
472                        cmd.strip_prefix(&prefix).map_or_else(
473                            || cmd.clone(),
474                            |base_cmd| {
475                                crate::logic::privilege::build_password_pipe(tool, pass, base_cmd)
476                                    .unwrap_or_else(|| cmd.clone())
477                            },
478                        )
479                    },
480                )
481            })
482            .collect()
483    };
484
485    let joined = processed_commands.join(" && ");
486
487    // Warm up privilege credentials so internal sudo/doas calls don't re-prompt.
488    if let Some(pass) = password {
489        let tool = crate::logic::privilege::active_tool()?;
490        if let Some(warmup) = crate::logic::privilege::build_credential_warmup(tool, pass) {
491            return Ok(format!("{warmup} ; {joined}"));
492        }
493    }
494
495    Ok(joined)
496}
497
498/// What: Build scan command string for `PTY` execution (excluding aur-sleuth).
499///
500/// Inputs:
501/// - `package`: Package name to scan.
502/// - `do_clamav`/`do_trivy`/`do_semgrep`/`do_shellcheck`/`do_virustotal`/`do_custom`: Scan configuration flags.
503/// - `dry_run`: Whether to run in dry-run mode.
504///
505/// Output:
506/// - Command string ready for `PTY` execution (no hold tail, excludes aur-sleuth).
507///
508/// Details:
509/// - Builds scan pipeline commands excluding aur-sleuth (which runs separately in terminal).
510/// - Sets environment variables for scan configuration.
511/// - Removes hold tail since we're not spawning a terminal.
512#[cfg(not(target_os = "windows"))]
513#[must_use]
514#[allow(clippy::fn_params_excessive_bools, clippy::too_many_arguments)]
515pub fn build_scan_command_for_executor(
516    package: &str,
517    do_clamav: bool,
518    do_trivy: bool,
519    do_semgrep: bool,
520    do_shellcheck: bool,
521    do_virustotal: bool,
522    do_custom: bool,
523    dry_run: bool,
524) -> String {
525    use super::utils::shell_single_quote;
526    use crate::install::scan::pkg::build_scan_cmds_for_pkg_without_sleuth;
527
528    // Prepend environment exports so subsequent steps honor the selection
529    let mut cmds: Vec<String> = Vec::new();
530    cmds.push(format!(
531        "export PACSEA_SCAN_DO_CLAMAV={}",
532        if do_clamav { "1" } else { "0" }
533    ));
534    cmds.push(format!(
535        "export PACSEA_SCAN_DO_TRIVY={}",
536        if do_trivy { "1" } else { "0" }
537    ));
538    cmds.push(format!(
539        "export PACSEA_SCAN_DO_SEMGREP={}",
540        if do_semgrep { "1" } else { "0" }
541    ));
542    cmds.push(format!(
543        "export PACSEA_SCAN_DO_SHELLCHECK={}",
544        if do_shellcheck { "1" } else { "0" }
545    ));
546    cmds.push(format!(
547        "export PACSEA_SCAN_DO_VIRUSTOTAL={}",
548        if do_virustotal { "1" } else { "0" }
549    ));
550    cmds.push(format!(
551        "export PACSEA_SCAN_DO_CUSTOM={}",
552        if do_custom { "1" } else { "0" }
553    ));
554    // Export default pattern sets
555    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());
556    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());
557    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());
558    cmds.push("export PACSEA_PATTERNS_LOW='http_proxy=|https_proxy=|ALL_PROXY=|yes[[:space:]]+> */dev/null *&|ulimit -n [0-9]{5,}'".to_string());
559
560    // Append the scan pipeline commands (excluding sleuth)
561    cmds.extend(build_scan_cmds_for_pkg_without_sleuth(package));
562
563    let full_cmd = cmds.join(" && ");
564
565    if dry_run {
566        let quoted = shell_single_quote(&full_cmd);
567        format!("echo DRY RUN: {quoted}")
568    } else {
569        full_cmd
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use crate::state::Source;
577
578    /// What: Create a test package item with specified source.
579    ///
580    /// Inputs:
581    /// - `name`: Package name
582    /// - `source`: Package source (Official or AUR)
583    ///
584    /// Output:
585    /// - `PackageItem` ready for testing
586    ///
587    /// Details:
588    /// - Helper to create test packages with consistent structure
589    fn create_test_package(name: &str, source: Source) -> PackageItem {
590        PackageItem {
591            name: name.into(),
592            version: "1.0.0".into(),
593            description: String::new(),
594            source,
595            popularity: None,
596            out_of_date: None,
597            orphaned: false,
598        }
599    }
600
601    #[test]
602    /// What: Verify executor command builder creates correct commands without hold tail.
603    ///
604    /// Inputs:
605    /// - Official and AUR packages.
606    /// - Optional password.
607    /// - Dry-run flag.
608    ///
609    /// Output:
610    /// - Commands without hold tail, suitable for PTY execution.
611    ///
612    /// Details:
613    /// - Ensures commands are properly formatted and don't include terminal hold prompts.
614    /// - Uses privilege abstraction so output adapts to active tool (sudo or doas).
615    fn executor_build_install_command_variants() {
616        let tool = crate::logic::privilege::active_tool().expect("privilege tool");
617        let bin = tool.binary_name();
618
619        let official_pkg = create_test_package(
620            "ripgrep",
621            Source::Official {
622                repo: "extra".into(),
623                arch: "x86_64".into(),
624            },
625        );
626
627        let aur_pkg = create_test_package("yay-bin", Source::Aur);
628
629        let installed_set = crate::logic::deps::get_installed_packages();
630        let provided_set = crate::logic::deps::get_provided_packages(&installed_set);
631        let is_installed = crate::logic::deps::is_package_installed_or_provided(
632            "ripgrep",
633            &installed_set,
634            &provided_set,
635        );
636        let cmd1 =
637            build_install_command_for_executor(std::slice::from_ref(&official_pkg), None, false)
638                .expect("build install");
639        let quoted_name = crate::install::shell_single_quote("ripgrep");
640        if is_installed {
641            assert!(
642                cmd1.contains(&format!("{bin} pacman -S --noconfirm {quoted_name}")),
643                "expected quoted package name in: {cmd1}"
644            );
645            assert!(!cmd1.contains("--needed"));
646        } else {
647            assert!(
648                cmd1.contains(&format!(
649                    "{bin} pacman -S --needed --noconfirm {quoted_name}"
650                )),
651                "expected quoted package name in: {cmd1}"
652            );
653        }
654        assert!(!cmd1.contains("Press any key to close"));
655
656        // Official package with password (only works when tool supports stdin password)
657        let cmd2 = build_install_command_for_executor(
658            std::slice::from_ref(&official_pkg),
659            Some("pass"),
660            false,
661        )
662        .expect("build install");
663        if tool.capabilities().supports_stdin_password {
664            assert!(cmd2.contains("printf "), "expected printf in: {cmd2}");
665            if is_installed {
666                assert!(
667                    cmd2.contains(&format!("{bin} -S pacman -S --noconfirm {quoted_name}")),
668                    "expected quoted package name in password command: {cmd2}"
669                );
670            } else {
671                assert!(
672                    cmd2.contains(&format!(
673                        "{bin} -S pacman -S --needed --noconfirm {quoted_name}"
674                    )),
675                    "expected quoted package name in password command: {cmd2}"
676                );
677            }
678        } else {
679            assert!(
680                cmd2.contains(&format!("{bin} pacman")),
681                "doas fallback should use plain command: {cmd2}"
682            );
683        }
684
685        // AUR package
686        let cmd3 = build_install_command_for_executor(std::slice::from_ref(&aur_pkg), None, false)
687            .expect("build install");
688        assert!(cmd3.contains("command -v paru"));
689        assert!(cmd3.contains("paru -S --aur"));
690        assert!(!cmd3.contains("Press any key to close"));
691
692        // Dry run
693        let cmd4 =
694            build_install_command_for_executor(&[official_pkg], None, true).expect("build install");
695        assert!(cmd4.starts_with("echo DRY RUN:"));
696    }
697
698    #[test]
699    /// What: Verify command builder handles mixed official and AUR packages.
700    ///
701    /// Inputs:
702    /// - Mixed list of official and AUR packages.
703    ///
704    /// Output:
705    /// - Command that installs all packages using appropriate tool.
706    ///
707    /// Details:
708    /// - Official names go through `pacman`; AUR names use `paru`/`yay` with `--aur` only on the AUR target list.
709    fn executor_build_mixed_packages() {
710        let tool = crate::logic::privilege::active_tool().expect("privilege tool");
711        let bin = tool.binary_name();
712
713        let official_pkg = create_test_package(
714            "ripgrep",
715            Source::Official {
716                repo: "extra".into(),
717                arch: "x86_64".into(),
718            },
719        );
720        let aur_pkg = create_test_package("yay-bin", Source::Aur);
721
722        let cmd = build_install_command_for_executor(&[official_pkg, aur_pkg], None, false)
723            .expect("build install");
724        assert!(
725            cmd.contains(&format!("{bin} pacman")),
726            "expected privileged pacman in mixed install: {cmd}"
727        );
728        assert!(
729            cmd.contains("ripgrep"),
730            "expected official pkg in cmd: {cmd}"
731        );
732        assert!(
733            cmd.contains("paru -S --aur") || cmd.contains("yay -S --aur"),
734            "expected helper --aur for AUR targets: {cmd}"
735        );
736        assert!(
737            cmd.contains("yay-bin"),
738            "expected AUR pkg name in helper invocation: {cmd}"
739        );
740    }
741
742    #[test]
743    /// What: Verify command builder handles empty package list.
744    ///
745    /// Inputs:
746    /// - Empty package list.
747    ///
748    /// Output:
749    /// - Command that indicates nothing to install.
750    ///
751    /// Details:
752    /// - Empty list should produce a safe no-op command.
753    fn executor_build_empty_list() {
754        let cmd = build_install_command_for_executor(&[], None, false).expect("build install");
755        assert!(cmd.contains("nothing to install") || cmd.is_empty());
756    }
757
758    #[test]
759    /// What: Verify command builder handles multiple official packages.
760    ///
761    /// Inputs:
762    /// - Multiple official packages.
763    ///
764    /// Output:
765    /// - Command that installs all packages via pacman.
766    ///
767    /// Details:
768    /// - Multiple packages should be space-separated in the command.
769    fn executor_build_multiple_official() {
770        let pkg1 = create_test_package(
771            "ripgrep",
772            Source::Official {
773                repo: "extra".into(),
774                arch: "x86_64".into(),
775            },
776        );
777        let pkg2 = create_test_package(
778            "fd",
779            Source::Official {
780                repo: "extra".into(),
781                arch: "x86_64".into(),
782            },
783        );
784
785        let installed_set = crate::logic::deps::get_installed_packages();
786        let provided_set = crate::logic::deps::get_provided_packages(&installed_set);
787        let ripgrep_installed = crate::logic::deps::is_package_installed_or_provided(
788            "ripgrep",
789            &installed_set,
790            &provided_set,
791        );
792        let fd_installed = crate::logic::deps::is_package_installed_or_provided(
793            "fd",
794            &installed_set,
795            &provided_set,
796        );
797        let has_reinstall = ripgrep_installed || fd_installed;
798
799        let cmd =
800            build_install_command_for_executor(&[pkg1, pkg2], None, false).expect("build install");
801        assert!(cmd.contains("ripgrep"));
802        assert!(cmd.contains("fd"));
803        let bin = crate::logic::privilege::active_tool()
804            .expect("privilege tool")
805            .binary_name();
806        if has_reinstall {
807            assert!(
808                cmd.contains(&format!("{bin} pacman -S --noconfirm")),
809                "expected '{bin} pacman -S --noconfirm' in: {cmd}"
810            );
811            assert!(!cmd.contains("--needed"));
812        } else {
813            assert!(
814                cmd.contains(&format!("{bin} pacman -S --needed --noconfirm")),
815                "expected '{bin} pacman -S --needed --noconfirm' in: {cmd}"
816            );
817        }
818    }
819
820    #[test]
821    /// What: Verify dry-run mode produces echo commands.
822    ///
823    /// Inputs:
824    /// - Package list with `dry_run=true`.
825    ///
826    /// Output:
827    /// - Command that starts with "echo DRY RUN:".
828    ///
829    /// Details:
830    /// - Dry-run should never execute actual install commands.
831    fn executor_build_dry_run() {
832        let pkg = create_test_package(
833            "ripgrep",
834            Source::Official {
835                repo: "extra".into(),
836                arch: "x86_64".into(),
837            },
838        );
839
840        let cmd = build_install_command_for_executor(&[pkg], None, true).expect("build install");
841        assert!(cmd.starts_with("echo DRY RUN:"));
842        // In dry-run mode, the command is wrapped in echo, so it may contain the original command text
843        // The important thing is that it starts with "echo DRY RUN:" which prevents execution
844    }
845
846    #[test]
847    /// What: Verify password is properly escaped in command.
848    ///
849    /// Inputs:
850    /// - Official package with password containing special characters.
851    ///
852    /// Output:
853    /// - Command with properly escaped password.
854    ///
855    /// Details:
856    /// - Password should be single-quoted to prevent shell injection.
857    fn executor_build_password_escaping() {
858        let tool = crate::logic::privilege::active_tool().expect("privilege tool");
859        let pkg = create_test_package(
860            "ripgrep",
861            Source::Official {
862                repo: "extra".into(),
863                arch: "x86_64".into(),
864            },
865        );
866
867        let password = "pass'word\"with$special";
868        let cmd = build_install_command_for_executor(&[pkg], Some(password), false)
869            .expect("build install");
870        if tool.capabilities().supports_stdin_password {
871            assert!(cmd.contains("printf"), "expected printf in: {cmd}");
872            assert!(
873                cmd.contains(&format!("{} -S", tool.binary_name())),
874                "expected '{} -S' in: {cmd}",
875                tool.binary_name()
876            );
877            // Password should be quoted/escaped when injected into a shell command.
878            assert!(
879                cmd.contains('\'') || cmd.contains('"'),
880                "password must be shell-escaped in: {cmd}"
881            );
882        } else {
883            // Tools without stdin password support (e.g. doas) must never embed the password.
884            assert!(
885                !cmd.contains(password),
886                "password must not be embedded for non-stdin tools: {cmd}"
887            );
888        }
889    }
890
891    #[test]
892    /// What: Verify remove command builder creates correct commands without hold tail.
893    ///
894    /// Inputs:
895    /// - Package names, cascade mode, optional password, dry-run flag.
896    ///
897    /// Output:
898    /// - Commands without hold tail, suitable for PTY execution.
899    ///
900    /// Details:
901    /// - Ensures commands are properly formatted and don't include terminal hold prompts.
902    fn executor_build_remove_command_variants() {
903        use crate::state::modal::CascadeMode;
904
905        let tool = crate::logic::privilege::active_tool().expect("privilege tool");
906        let bin = tool.binary_name();
907        let names = vec!["test-pkg1".to_string(), "test-pkg2".to_string()];
908
909        // Basic mode without password
910        let cmd1 = build_remove_command_for_executor(&names, None, CascadeMode::Basic, false)
911            .expect("build remove");
912        assert!(
913            cmd1.contains(&format!("{bin} pacman -R --noconfirm")),
914            "expected '{bin} pacman -R --noconfirm' in: {cmd1}"
915        );
916        assert!(cmd1.contains("test-pkg1"));
917        assert!(cmd1.contains("test-pkg2"));
918        assert!(!cmd1.contains("Press any key to close"));
919
920        // Cascade mode with password
921        let cmd2 =
922            build_remove_command_for_executor(&names, Some("pass"), CascadeMode::Cascade, false)
923                .expect("build remove");
924        if tool.capabilities().supports_stdin_password {
925            assert!(cmd2.contains("printf "), "expected printf in: {cmd2}");
926            assert!(
927                cmd2.contains(&format!("{bin} -S pacman -Rs --noconfirm")),
928                "expected '{bin} -S pacman -Rs --noconfirm' in: {cmd2}"
929            );
930        } else {
931            assert!(
932                cmd2.contains(&format!("{bin} pacman -Rs --noconfirm")),
933                "expected '{bin} pacman -Rs --noconfirm' in: {cmd2}"
934            );
935        }
936
937        // CascadeWithConfigs mode
938        let cmd3 =
939            build_remove_command_for_executor(&names, None, CascadeMode::CascadeWithConfigs, false)
940                .expect("build remove");
941        assert!(
942            cmd3.contains(&format!("{bin} pacman -Rns --noconfirm")),
943            "expected '{bin} pacman -Rns --noconfirm' in: {cmd3}"
944        );
945
946        // Dry run
947        let cmd4 = build_remove_command_for_executor(&names, None, CascadeMode::Basic, true)
948            .expect("build remove");
949        assert!(cmd4.starts_with("echo DRY RUN:"));
950        assert!(cmd4.contains("pacman -R --noconfirm"));
951
952        // Empty list
953        let cmd5 = build_remove_command_for_executor(&[], None, CascadeMode::Basic, false)
954            .expect("build remove");
955        assert_eq!(cmd5, "echo nothing to remove");
956    }
957
958    #[test]
959    /// What: Verify downgrade executor command uses tool-aware execution and unprivileged package check.
960    ///
961    /// Inputs:
962    /// - Non-empty package names list.
963    /// - Dry-run and non-dry-run modes.
964    ///
965    /// Output:
966    /// - Dry-run command prefixed with `echo DRY RUN:`.
967    /// - Live command that checks `downgrade` availability and runs `<tool> downgrade ...`.
968    ///
969    /// Details:
970    /// - `pacman -Qi downgrade` must remain unprivileged because it is read-only.
971    fn executor_build_downgrade_command_uses_unprivileged_qi_check() {
972        let names = vec!["linux".to_string(), "linux-headers".to_string()];
973        let tool = crate::logic::privilege::active_tool().expect("privilege tool");
974        let bin = tool.binary_name();
975
976        let live =
977            build_downgrade_command_for_executor(&names, None, false).expect("build downgrade");
978        assert!(
979            live.contains("pacman -Qi downgrade"),
980            "expected unprivileged pacman -Qi check in: {live}"
981        );
982        assert!(
983            live.contains(&format!("{bin} downgrade linux linux-headers")),
984            "expected tool-aware downgrade command in: {live}"
985        );
986
987        let dry =
988            build_downgrade_command_for_executor(&names, None, true).expect("build downgrade");
989        assert!(dry.starts_with("echo DRY RUN:"));
990        assert!(dry.contains(&format!("{bin} downgrade linux linux-headers")));
991    }
992}