//! PTY-based command executor for in-TUI execution.
use crate::state::SecureString;
use crate::state::{PackageItem, modal::CascadeMode};
/// What: Request types for command execution.
///
/// Inputs:
/// - Various operation types (Install, Remove, etc.) with their parameters.
///
/// Output:
/// - Sent to executor worker to trigger command execution.
///
/// Details:
/// - Each variant contains all necessary information to build and execute the command.
#[derive(Debug, Clone)]
pub enum ExecutorRequest {
/// Install packages.
Install {
/// Packages to install.
items: Vec<PackageItem>,
/// Optional sudo password for official packages.
password: Option<SecureString>,
/// Whether to run in dry-run mode.
dry_run: bool,
},
/// Remove packages.
Remove {
/// Package names to remove.
names: Vec<String>,
/// Optional sudo password.
password: Option<SecureString>,
/// Cascade removal mode.
cascade: CascadeMode,
/// Whether to run in dry-run mode.
dry_run: bool,
},
/// Downgrade packages.
Downgrade {
/// Package names to downgrade.
names: Vec<String>,
/// Optional sudo password.
password: Option<SecureString>,
/// Whether to run in dry-run mode.
dry_run: bool,
},
/// Custom command execution (for special cases like paru/yay installation).
CustomCommand {
/// Command string to execute.
command: String,
/// Optional sudo password for commands that need sudo (e.g., makepkg -si).
password: Option<SecureString>,
/// Whether to run in dry-run mode.
dry_run: bool,
},
/// System update (mirrors, pacman, AUR, cache).
Update {
/// Commands to execute in sequence.
commands: Vec<String>,
/// Optional sudo password for commands that need sudo.
password: Option<SecureString>,
/// Whether to run in dry-run mode.
dry_run: bool,
},
/// Security scan for AUR package (excluding aur-sleuth).
Scan {
/// Package name to scan.
package: String,
/// Scan configuration flags.
do_clamav: bool,
/// Trivy scan flag.
do_trivy: bool,
/// Semgrep scan flag.
do_semgrep: bool,
/// `ShellCheck` scan flag.
do_shellcheck: bool,
/// `VirusTotal` scan flag.
do_virustotal: bool,
/// Custom pattern scan flag.
do_custom: bool,
/// Whether to run in dry-run mode.
dry_run: bool,
},
}
/// What: Output messages from command execution.
///
/// Inputs:
/// - Generated by executor worker during command execution.
///
/// Output:
/// - Sent to main event loop for display in `PreflightExec` modal.
///
/// Details:
/// - Line messages contain output from the `PTY`, Finished indicates completion.
#[derive(Debug, Clone)]
pub enum ExecutorOutput {
/// Output line from command execution.
Line(String),
/// Replace the last line (used for progress bars with carriage return).
ReplaceLastLine(String),
/// Command execution finished.
Finished {
/// Whether execution succeeded.
success: bool,
/// Exit code if available.
exit_code: Option<i32>,
/// Name of the failed command (if execution failed and this is an update operation).
failed_command: Option<String>,
},
/// Error occurred during execution.
Error(String),
}
/// What: Build install command string without hold tail for `PTY` execution.
///
/// Inputs:
/// - `items`: Packages to install.
/// - `_password`: Optional sudo password (unused - password is written to PTY stdin when sudo prompts).
/// - `dry_run`: Whether to run in dry-run mode.
///
/// Output:
/// - Command string ready for `PTY` execution (no hold tail).
///
/// Details:
/// - Groups official and `AUR` packages separately; mixed installs run `pacman` then `paru`/`yay` with `--aur` on **AUR-only** names.
/// - Uses `--noconfirm` for non-interactive execution.
/// - Always uses `sudo -S` for official packages (password written to PTY stdin when sudo prompts).
/// - Removes hold tail since we're not spawning a terminal.
///
/// # Errors
///
/// Returns `Err` when the configured privilege tool cannot be resolved for official package paths.
pub fn build_install_command_for_executor(
items: &[PackageItem],
password: Option<&str>,
dry_run: bool,
) -> Result<String, String> {
use super::command::{aur_install_body, aur_install_helper_flags};
use super::utils::{shell_single_quote, validate_package_names};
use crate::state::Source;
let mut official: Vec<String> = Vec::new();
let mut aur: Vec<String> = Vec::new();
for item in items {
match item.source {
Source::Official { .. } => official.push(item.name.clone()),
Source::Aur => aur.push(item.name.clone()),
}
}
validate_package_names(&official, "executor install command (official)")?;
validate_package_names(&aur, "executor install command (AUR)")?;
let official_quoted: Vec<String> = official
.iter()
.map(|name| shell_single_quote(name))
.collect();
let aur_quoted: Vec<String> = aur.iter().map(|name| shell_single_quote(name)).collect();
let installed_set = crate::logic::deps::get_installed_packages();
let provided_set = crate::logic::deps::get_provided_packages(&installed_set);
let official_has_reinstall = official.iter().any(|name| {
crate::logic::deps::is_package_installed_or_provided(name, &installed_set, &provided_set)
});
let pacman_flags = if official_has_reinstall {
"--noconfirm"
} else {
"--needed --noconfirm"
};
let aur_has_reinstall = aur.iter().any(|name| {
crate::logic::deps::is_package_installed_or_provided(name, &installed_set, &provided_set)
});
let aur_s_flags = aur_install_helper_flags(aur_has_reinstall);
let aur_cli_suffix = if aur_has_reinstall {
"--noconfirm"
} else {
"--needed --noconfirm"
};
let aur_names = aur_quoted.join(" ");
if dry_run {
if !aur.is_empty() && !official.is_empty() {
let tool = crate::logic::privilege::active_tool()?;
let off_cmd = crate::logic::privilege::build_privilege_command(
tool,
&format!("pacman -S {pacman_flags} {}", official_quoted.join(" ")),
);
let aur_cmd = format!(
"(paru -S --aur {aur_cli_suffix} {aur_names} || yay -S --aur {aur_cli_suffix} {aur_names})",
);
let combined = format!("{off_cmd} && {aur_cmd}");
let quoted = shell_single_quote(&combined);
Ok(format!("echo DRY RUN: {quoted}"))
} else if !aur.is_empty() {
let aur_cmd = format!(
"(paru -S --aur {aur_cli_suffix} {aur_names} || yay -S --aur {aur_cli_suffix} {aur_names})",
);
let quoted = shell_single_quote(&aur_cmd);
Ok(format!("echo DRY RUN: {quoted}"))
} else if !official.is_empty() {
let tool = crate::logic::privilege::active_tool()?;
let cmd = crate::logic::privilege::build_privilege_command(
tool,
&format!("pacman -S {pacman_flags} {}", official_quoted.join(" ")),
);
let quoted = shell_single_quote(&cmd);
Ok(format!("echo DRY RUN: {quoted}"))
} else {
Ok("echo DRY RUN: nothing to install".to_string())
}
} else if !aur.is_empty() && !official.is_empty() {
let tool = crate::logic::privilege::active_tool()?;
let install_cmd = format!("pacman -S {pacman_flags} {}", official_quoted.join(" "));
let official_chain = password.map_or_else(
|| {
let sync = crate::logic::privilege::build_privilege_command(tool, "pacman -Sy");
let install = crate::logic::privilege::build_privilege_command(tool, &install_cmd);
format!("{sync} && {install}")
},
|pass| {
crate::logic::privilege::build_password_pipe(tool, pass, "pacman -Sy").map_or_else(
|| {
let sync =
crate::logic::privilege::build_privilege_command(tool, "pacman -Sy");
let install =
crate::logic::privilege::build_privilege_command(tool, &install_cmd);
format!("{sync} && {install}")
},
|sync_pipe| {
let install_pipe =
crate::logic::privilege::build_password_pipe(tool, pass, &install_cmd)
.unwrap_or_else(|| {
crate::logic::privilege::build_privilege_command(
tool,
&install_cmd,
)
});
format!("{sync_pipe} && {install_pipe}")
},
)
},
);
Ok(format!(
"{} && {}",
official_chain,
aur_install_body(aur_s_flags, &aur_names)
))
} else if !aur.is_empty() {
Ok(aur_install_body(aur_s_flags, &aur_names))
} else if !official.is_empty() {
let tool = crate::logic::privilege::active_tool()?;
let install_cmd = format!("pacman -S {pacman_flags} {}", official_quoted.join(" "));
Ok(password.map_or_else(
|| {
let sync = crate::logic::privilege::build_privilege_command(tool, "pacman -Sy");
let install = crate::logic::privilege::build_privilege_command(tool, &install_cmd);
format!("{sync} && {install}")
},
|pass| {
crate::logic::privilege::build_password_pipe(tool, pass, "pacman -Sy").map_or_else(
|| {
let sync =
crate::logic::privilege::build_privilege_command(tool, "pacman -Sy");
let install =
crate::logic::privilege::build_privilege_command(tool, &install_cmd);
format!("{sync} && {install}")
},
|sync_pipe| {
let install_pipe =
crate::logic::privilege::build_password_pipe(tool, pass, &install_cmd)
.unwrap_or_else(|| {
crate::logic::privilege::build_privilege_command(
tool,
&install_cmd,
)
});
format!("{sync_pipe} && {install_pipe}")
},
)
},
))
} else {
Ok("echo nothing to install".to_string())
}
}
/// What: Build remove command string without hold tail for `PTY` execution.
///
/// Inputs:
/// - `names`: Package names to remove.
/// - `password`: Optional sudo password (password is written to PTY stdin when sudo prompts).
/// - `cascade`: Cascade removal mode.
/// - `dry_run`: Whether to run in dry-run mode.
///
/// Output:
/// - Command string ready for `PTY` execution (no hold tail).
///
/// Details:
/// - Uses `-R`, `-Rs`, or `-Rns` based on cascade mode.
/// - Uses `--noconfirm` for non-interactive execution.
/// - Always uses `sudo -S` for remove operations (password written to PTY stdin when sudo prompts).
/// - Removes hold tail since we're not spawning a terminal.
///
/// # Errors
///
/// Returns `Err` when the configured privilege tool cannot be resolved.
pub fn build_remove_command_for_executor(
names: &[String],
password: Option<&str>,
cascade: crate::state::modal::CascadeMode,
dry_run: bool,
) -> Result<String, String> {
use super::utils::shell_single_quote;
if names.is_empty() {
return Ok(if dry_run {
"echo DRY RUN: nothing to remove".to_string()
} else {
"echo nothing to remove".to_string()
});
}
let flag = cascade.flag();
let names_str = names.join(" ");
let tool = crate::logic::privilege::active_tool()?;
let base_cmd = format!("pacman {flag} --noconfirm {names_str}");
if dry_run {
let cmd = crate::logic::privilege::build_privilege_command(tool, &base_cmd);
let quoted = shell_single_quote(&cmd);
Ok(format!("echo DRY RUN: {quoted}"))
} else {
Ok(password.map_or_else(
|| crate::logic::privilege::build_privilege_command(tool, &base_cmd),
|pass| {
crate::logic::privilege::build_password_pipe(tool, pass, &base_cmd).unwrap_or_else(
|| crate::logic::privilege::build_privilege_command(tool, &base_cmd),
)
},
))
}
}
/// What: Build downgrade command string without hold tail for `PTY` execution.
///
/// Inputs:
/// - `names`: Package names to downgrade.
/// - `_password`: Optional password (unused - downgrade runs interactively in PTY).
/// - `dry_run`: Whether to run in dry-run mode.
///
/// Output:
/// - Command string ready for `PTY` execution (no hold tail).
///
/// Details:
/// - Uses the `downgrade` tool to downgrade packages.
/// - Checks if `downgrade` tool is available before executing.
/// - Read-only package checks use `pacman -Qi` without privilege escalation.
/// - Removes hold tail since we're not spawning a terminal.
///
/// # Errors
///
/// Returns `Err` when the configured privilege tool cannot be resolved.
pub fn build_downgrade_command_for_executor(
names: &[String],
_password: Option<&str>,
dry_run: bool,
) -> Result<String, String> {
use super::utils::shell_single_quote;
if names.is_empty() {
return Ok(if dry_run {
"echo DRY RUN: nothing to downgrade".to_string()
} else {
"echo nothing to downgrade".to_string()
});
}
let names_str = names.join(" ");
let tool = crate::logic::privilege::active_tool()?;
let bin = tool.binary_name();
if dry_run {
let cmd = crate::logic::privilege::build_privilege_command(
tool,
&format!("downgrade {names_str}"),
);
let quoted = shell_single_quote(&cmd);
Ok(format!("echo DRY RUN: {quoted}"))
} else {
Ok(format!(
"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"
))
}
}
/// What: Build system update command string by chaining multiple commands.
///
/// Inputs:
/// - `commands`: List of commands to execute in sequence.
/// - `password`: Optional sudo password for commands that need sudo.
/// - `dry_run`: Whether to run in dry-run mode.
///
/// Output:
/// - Command string ready for `PTY` execution (commands chained with `&&`).
///
/// Details:
/// - Chains commands with `&&` so execution stops on first failure.
/// - For commands starting with `sudo`, pipes password if provided.
/// - In dry-run mode, wraps each command in `echo DRY RUN:`.
/// - Removes hold tail since we're not spawning a terminal.
///
/// # Errors
///
/// Returns `Err` when the configured privilege tool cannot be resolved for non-dry-run paths
/// or credential warm-up.
pub fn build_update_command_for_executor(
commands: &[String],
password: Option<&str>,
dry_run: bool,
) -> Result<String, String> {
use super::utils::shell_single_quote;
if commands.is_empty() {
return Ok(if dry_run {
"echo DRY RUN: nothing to update".to_string()
} else {
"echo nothing to update".to_string()
});
}
let processed_commands: Vec<String> = if dry_run {
// Check if we should simulate failure for testing (first command only, if it's pacman)
let simulate_failure = std::env::var("PACSEA_TEST_SIMULATE_PACMAN_FAILURE").is_ok()
&& !commands.is_empty()
&& commands[0].contains("pacman");
if simulate_failure {
tracing::info!(
"[DRY-RUN] Simulating pacman failure for testing - first command will fail with exit code 1"
);
}
commands
.iter()
.enumerate()
.map(|(idx, c)| {
// Properly quote the command to avoid syntax errors with complex shell constructs
let quoted = shell_single_quote(c);
if simulate_failure && idx == 0 {
// Simulate pacman failure for testing confirmation popup
// Use false to ensure the command fails with exit code 1
// The && will prevent subsequent commands from running
format!("echo DRY RUN: {quoted} && false")
} else {
format!("echo DRY RUN: {quoted}")
}
})
.collect()
} else {
let tool = crate::logic::privilege::active_tool()?;
let prefix = format!("{} ", tool.binary_name());
commands
.iter()
.map(|cmd| {
password.map_or_else(
|| cmd.clone(),
|pass| {
cmd.strip_prefix(&prefix).map_or_else(
|| cmd.clone(),
|base_cmd| {
crate::logic::privilege::build_password_pipe(tool, pass, base_cmd)
.unwrap_or_else(|| cmd.clone())
},
)
},
)
})
.collect()
};
let joined = processed_commands.join(" && ");
// Warm up privilege credentials so internal sudo/doas calls don't re-prompt.
if let Some(pass) = password {
let tool = crate::logic::privilege::active_tool()?;
if let Some(warmup) = crate::logic::privilege::build_credential_warmup(tool, pass) {
return Ok(format!("{warmup} ; {joined}"));
}
}
Ok(joined)
}
/// What: Build scan command string for `PTY` execution (excluding aur-sleuth).
///
/// Inputs:
/// - `package`: Package name to scan.
/// - `do_clamav`/`do_trivy`/`do_semgrep`/`do_shellcheck`/`do_virustotal`/`do_custom`: Scan configuration flags.
/// - `dry_run`: Whether to run in dry-run mode.
///
/// Output:
/// - Command string ready for `PTY` execution (no hold tail, excludes aur-sleuth).
///
/// Details:
/// - Builds scan pipeline commands excluding aur-sleuth (which runs separately in terminal).
/// - Sets environment variables for scan configuration.
/// - Removes hold tail since we're not spawning a terminal.
#[cfg(not(target_os = "windows"))]
#[must_use]
#[allow(clippy::fn_params_excessive_bools, clippy::too_many_arguments)]
pub fn build_scan_command_for_executor(
package: &str,
do_clamav: bool,
do_trivy: bool,
do_semgrep: bool,
do_shellcheck: bool,
do_virustotal: bool,
do_custom: bool,
dry_run: bool,
) -> String {
use super::utils::shell_single_quote;
use crate::install::scan::pkg::build_scan_cmds_for_pkg_without_sleuth;
// Prepend environment exports so subsequent steps honor the selection
let mut cmds: Vec<String> = Vec::new();
cmds.push(format!(
"export PACSEA_SCAN_DO_CLAMAV={}",
if do_clamav { "1" } else { "0" }
));
cmds.push(format!(
"export PACSEA_SCAN_DO_TRIVY={}",
if do_trivy { "1" } else { "0" }
));
cmds.push(format!(
"export PACSEA_SCAN_DO_SEMGREP={}",
if do_semgrep { "1" } else { "0" }
));
cmds.push(format!(
"export PACSEA_SCAN_DO_SHELLCHECK={}",
if do_shellcheck { "1" } else { "0" }
));
cmds.push(format!(
"export PACSEA_SCAN_DO_VIRUSTOTAL={}",
if do_virustotal { "1" } else { "0" }
));
cmds.push(format!(
"export PACSEA_SCAN_DO_CUSTOM={}",
if do_custom { "1" } else { "0" }
));
// Export default pattern sets
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());
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());
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());
cmds.push("export PACSEA_PATTERNS_LOW='http_proxy=|https_proxy=|ALL_PROXY=|yes[[:space:]]+> */dev/null *&|ulimit -n [0-9]{5,}'".to_string());
// Append the scan pipeline commands (excluding sleuth)
cmds.extend(build_scan_cmds_for_pkg_without_sleuth(package));
let full_cmd = cmds.join(" && ");
if dry_run {
let quoted = shell_single_quote(&full_cmd);
format!("echo DRY RUN: {quoted}")
} else {
full_cmd
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::Source;
/// What: Create a test package item with specified source.
///
/// Inputs:
/// - `name`: Package name
/// - `source`: Package source (Official or AUR)
///
/// Output:
/// - `PackageItem` ready for testing
///
/// Details:
/// - Helper to create test packages with consistent structure
fn create_test_package(name: &str, source: Source) -> PackageItem {
PackageItem {
name: name.into(),
version: "1.0.0".into(),
description: String::new(),
source,
popularity: None,
out_of_date: None,
orphaned: false,
}
}
#[test]
/// What: Verify executor command builder creates correct commands without hold tail.
///
/// Inputs:
/// - Official and AUR packages.
/// - Optional password.
/// - Dry-run flag.
///
/// Output:
/// - Commands without hold tail, suitable for PTY execution.
///
/// Details:
/// - Ensures commands are properly formatted and don't include terminal hold prompts.
/// - Uses privilege abstraction so output adapts to active tool (sudo or doas).
fn executor_build_install_command_variants() {
let tool = crate::logic::privilege::active_tool().expect("privilege tool");
let bin = tool.binary_name();
let official_pkg = create_test_package(
"ripgrep",
Source::Official {
repo: "extra".into(),
arch: "x86_64".into(),
},
);
let aur_pkg = create_test_package("yay-bin", Source::Aur);
let installed_set = crate::logic::deps::get_installed_packages();
let provided_set = crate::logic::deps::get_provided_packages(&installed_set);
let is_installed = crate::logic::deps::is_package_installed_or_provided(
"ripgrep",
&installed_set,
&provided_set,
);
let cmd1 =
build_install_command_for_executor(std::slice::from_ref(&official_pkg), None, false)
.expect("build install");
let quoted_name = crate::install::shell_single_quote("ripgrep");
if is_installed {
assert!(
cmd1.contains(&format!("{bin} pacman -S --noconfirm {quoted_name}")),
"expected quoted package name in: {cmd1}"
);
assert!(!cmd1.contains("--needed"));
} else {
assert!(
cmd1.contains(&format!(
"{bin} pacman -S --needed --noconfirm {quoted_name}"
)),
"expected quoted package name in: {cmd1}"
);
}
assert!(!cmd1.contains("Press any key to close"));
// Official package with password (only works when tool supports stdin password)
let cmd2 = build_install_command_for_executor(
std::slice::from_ref(&official_pkg),
Some("pass"),
false,
)
.expect("build install");
if tool.capabilities().supports_stdin_password {
assert!(cmd2.contains("printf "), "expected printf in: {cmd2}");
if is_installed {
assert!(
cmd2.contains(&format!("{bin} -S pacman -S --noconfirm {quoted_name}")),
"expected quoted package name in password command: {cmd2}"
);
} else {
assert!(
cmd2.contains(&format!(
"{bin} -S pacman -S --needed --noconfirm {quoted_name}"
)),
"expected quoted package name in password command: {cmd2}"
);
}
} else {
assert!(
cmd2.contains(&format!("{bin} pacman")),
"doas fallback should use plain command: {cmd2}"
);
}
// AUR package
let cmd3 = build_install_command_for_executor(std::slice::from_ref(&aur_pkg), None, false)
.expect("build install");
assert!(cmd3.contains("command -v paru"));
assert!(cmd3.contains("paru -S --aur"));
assert!(!cmd3.contains("Press any key to close"));
// Dry run
let cmd4 =
build_install_command_for_executor(&[official_pkg], None, true).expect("build install");
assert!(cmd4.starts_with("echo DRY RUN:"));
}
#[test]
/// What: Verify command builder handles mixed official and AUR packages.
///
/// Inputs:
/// - Mixed list of official and AUR packages.
///
/// Output:
/// - Command that installs all packages using appropriate tool.
///
/// Details:
/// - Official names go through `pacman`; AUR names use `paru`/`yay` with `--aur` only on the AUR target list.
fn executor_build_mixed_packages() {
let tool = crate::logic::privilege::active_tool().expect("privilege tool");
let bin = tool.binary_name();
let official_pkg = create_test_package(
"ripgrep",
Source::Official {
repo: "extra".into(),
arch: "x86_64".into(),
},
);
let aur_pkg = create_test_package("yay-bin", Source::Aur);
let cmd = build_install_command_for_executor(&[official_pkg, aur_pkg], None, false)
.expect("build install");
assert!(
cmd.contains(&format!("{bin} pacman")),
"expected privileged pacman in mixed install: {cmd}"
);
assert!(
cmd.contains("ripgrep"),
"expected official pkg in cmd: {cmd}"
);
assert!(
cmd.contains("paru -S --aur") || cmd.contains("yay -S --aur"),
"expected helper --aur for AUR targets: {cmd}"
);
assert!(
cmd.contains("yay-bin"),
"expected AUR pkg name in helper invocation: {cmd}"
);
}
#[test]
/// What: Verify command builder handles empty package list.
///
/// Inputs:
/// - Empty package list.
///
/// Output:
/// - Command that indicates nothing to install.
///
/// Details:
/// - Empty list should produce a safe no-op command.
fn executor_build_empty_list() {
let cmd = build_install_command_for_executor(&[], None, false).expect("build install");
assert!(cmd.contains("nothing to install") || cmd.is_empty());
}
#[test]
/// What: Verify command builder handles multiple official packages.
///
/// Inputs:
/// - Multiple official packages.
///
/// Output:
/// - Command that installs all packages via pacman.
///
/// Details:
/// - Multiple packages should be space-separated in the command.
fn executor_build_multiple_official() {
let pkg1 = create_test_package(
"ripgrep",
Source::Official {
repo: "extra".into(),
arch: "x86_64".into(),
},
);
let pkg2 = create_test_package(
"fd",
Source::Official {
repo: "extra".into(),
arch: "x86_64".into(),
},
);
let installed_set = crate::logic::deps::get_installed_packages();
let provided_set = crate::logic::deps::get_provided_packages(&installed_set);
let ripgrep_installed = crate::logic::deps::is_package_installed_or_provided(
"ripgrep",
&installed_set,
&provided_set,
);
let fd_installed = crate::logic::deps::is_package_installed_or_provided(
"fd",
&installed_set,
&provided_set,
);
let has_reinstall = ripgrep_installed || fd_installed;
let cmd =
build_install_command_for_executor(&[pkg1, pkg2], None, false).expect("build install");
assert!(cmd.contains("ripgrep"));
assert!(cmd.contains("fd"));
let bin = crate::logic::privilege::active_tool()
.expect("privilege tool")
.binary_name();
if has_reinstall {
assert!(
cmd.contains(&format!("{bin} pacman -S --noconfirm")),
"expected '{bin} pacman -S --noconfirm' in: {cmd}"
);
assert!(!cmd.contains("--needed"));
} else {
assert!(
cmd.contains(&format!("{bin} pacman -S --needed --noconfirm")),
"expected '{bin} pacman -S --needed --noconfirm' in: {cmd}"
);
}
}
#[test]
/// What: Verify dry-run mode produces echo commands.
///
/// Inputs:
/// - Package list with `dry_run=true`.
///
/// Output:
/// - Command that starts with "echo DRY RUN:".
///
/// Details:
/// - Dry-run should never execute actual install commands.
fn executor_build_dry_run() {
let pkg = create_test_package(
"ripgrep",
Source::Official {
repo: "extra".into(),
arch: "x86_64".into(),
},
);
let cmd = build_install_command_for_executor(&[pkg], None, true).expect("build install");
assert!(cmd.starts_with("echo DRY RUN:"));
// In dry-run mode, the command is wrapped in echo, so it may contain the original command text
// The important thing is that it starts with "echo DRY RUN:" which prevents execution
}
#[test]
/// What: Verify password is properly escaped in command.
///
/// Inputs:
/// - Official package with password containing special characters.
///
/// Output:
/// - Command with properly escaped password.
///
/// Details:
/// - Password should be single-quoted to prevent shell injection.
fn executor_build_password_escaping() {
let tool = crate::logic::privilege::active_tool().expect("privilege tool");
let pkg = create_test_package(
"ripgrep",
Source::Official {
repo: "extra".into(),
arch: "x86_64".into(),
},
);
let password = "pass'word\"with$special";
let cmd = build_install_command_for_executor(&[pkg], Some(password), false)
.expect("build install");
if tool.capabilities().supports_stdin_password {
assert!(cmd.contains("printf"), "expected printf in: {cmd}");
assert!(
cmd.contains(&format!("{} -S", tool.binary_name())),
"expected '{} -S' in: {cmd}",
tool.binary_name()
);
// Password should be quoted/escaped when injected into a shell command.
assert!(
cmd.contains('\'') || cmd.contains('"'),
"password must be shell-escaped in: {cmd}"
);
} else {
// Tools without stdin password support (e.g. doas) must never embed the password.
assert!(
!cmd.contains(password),
"password must not be embedded for non-stdin tools: {cmd}"
);
}
}
#[test]
/// What: Verify remove command builder creates correct commands without hold tail.
///
/// Inputs:
/// - Package names, cascade mode, optional password, dry-run flag.
///
/// Output:
/// - Commands without hold tail, suitable for PTY execution.
///
/// Details:
/// - Ensures commands are properly formatted and don't include terminal hold prompts.
fn executor_build_remove_command_variants() {
use crate::state::modal::CascadeMode;
let tool = crate::logic::privilege::active_tool().expect("privilege tool");
let bin = tool.binary_name();
let names = vec!["test-pkg1".to_string(), "test-pkg2".to_string()];
// Basic mode without password
let cmd1 = build_remove_command_for_executor(&names, None, CascadeMode::Basic, false)
.expect("build remove");
assert!(
cmd1.contains(&format!("{bin} pacman -R --noconfirm")),
"expected '{bin} pacman -R --noconfirm' in: {cmd1}"
);
assert!(cmd1.contains("test-pkg1"));
assert!(cmd1.contains("test-pkg2"));
assert!(!cmd1.contains("Press any key to close"));
// Cascade mode with password
let cmd2 =
build_remove_command_for_executor(&names, Some("pass"), CascadeMode::Cascade, false)
.expect("build remove");
if tool.capabilities().supports_stdin_password {
assert!(cmd2.contains("printf "), "expected printf in: {cmd2}");
assert!(
cmd2.contains(&format!("{bin} -S pacman -Rs --noconfirm")),
"expected '{bin} -S pacman -Rs --noconfirm' in: {cmd2}"
);
} else {
assert!(
cmd2.contains(&format!("{bin} pacman -Rs --noconfirm")),
"expected '{bin} pacman -Rs --noconfirm' in: {cmd2}"
);
}
// CascadeWithConfigs mode
let cmd3 =
build_remove_command_for_executor(&names, None, CascadeMode::CascadeWithConfigs, false)
.expect("build remove");
assert!(
cmd3.contains(&format!("{bin} pacman -Rns --noconfirm")),
"expected '{bin} pacman -Rns --noconfirm' in: {cmd3}"
);
// Dry run
let cmd4 = build_remove_command_for_executor(&names, None, CascadeMode::Basic, true)
.expect("build remove");
assert!(cmd4.starts_with("echo DRY RUN:"));
assert!(cmd4.contains("pacman -R --noconfirm"));
// Empty list
let cmd5 = build_remove_command_for_executor(&[], None, CascadeMode::Basic, false)
.expect("build remove");
assert_eq!(cmd5, "echo nothing to remove");
}
#[test]
/// What: Verify downgrade executor command uses tool-aware execution and unprivileged package check.
///
/// Inputs:
/// - Non-empty package names list.
/// - Dry-run and non-dry-run modes.
///
/// Output:
/// - Dry-run command prefixed with `echo DRY RUN:`.
/// - Live command that checks `downgrade` availability and runs `<tool> downgrade ...`.
///
/// Details:
/// - `pacman -Qi downgrade` must remain unprivileged because it is read-only.
fn executor_build_downgrade_command_uses_unprivileged_qi_check() {
let names = vec!["linux".to_string(), "linux-headers".to_string()];
let tool = crate::logic::privilege::active_tool().expect("privilege tool");
let bin = tool.binary_name();
let live =
build_downgrade_command_for_executor(&names, None, false).expect("build downgrade");
assert!(
live.contains("pacman -Qi downgrade"),
"expected unprivileged pacman -Qi check in: {live}"
);
assert!(
live.contains(&format!("{bin} downgrade linux linux-headers")),
"expected tool-aware downgrade command in: {live}"
);
let dry =
build_downgrade_command_for_executor(&names, None, true).expect("build downgrade");
assert!(dry.starts_with("echo DRY RUN:"));
assert!(dry.contains(&format!("{bin} downgrade linux linux-headers")));
}
}