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}