Skip to main content

rusty_stack/installers/common/
package_manager.rs

1//! Package manager abstraction for install/remove/query operations.
2//!
3//! Provides a unified interface for system package managers (apt, dnf, pacman,
4//! yum, zypper) with dry-run support. Command construction is tested without
5//! actually executing subprocesses.
6//!
7//! # Validation Assertions
8//!
9//! - **VAL-INFRA-005**: Package manager operations correct
10//! - **VAL-INFRA-006**: Package manager supports dry-run mode
11
12use crate::platform::detection::PackageManager;
13use std::fmt;
14use std::process::Command;
15
16/// Type of package operation being performed.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum PackageOperation {
19    Install,
20    Remove,
21    Purge,
22    Update,
23    Upgrade,
24    Search,
25    Info,
26    IsInstalled,
27    ListInstalled,
28}
29
30impl fmt::Display for PackageOperation {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        match self {
33            PackageOperation::Install => write!(f, "install"),
34            PackageOperation::Remove => write!(f, "remove"),
35            PackageOperation::Purge => write!(f, "purge"),
36            PackageOperation::Update => write!(f, "update"),
37            PackageOperation::Upgrade => write!(f, "upgrade"),
38            PackageOperation::Search => write!(f, "search"),
39            PackageOperation::Info => write!(f, "info"),
40            PackageOperation::IsInstalled => write!(f, "is_installed"),
41            PackageOperation::ListInstalled => write!(f, "list_installed"),
42        }
43    }
44}
45
46/// Result of a dry-run operation — contains the command that would be executed.
47#[derive(Debug, Clone)]
48pub struct DryRunResult {
49    /// The program that would be executed (e.g., "sudo", "apt").
50    pub program: String,
51    /// The arguments that would be passed.
52    pub args: Vec<String>,
53    /// Human-readable description of the operation.
54    pub description: String,
55}
56
57impl DryRunResult {
58    /// Format the full command as a string.
59    pub fn to_command_string(&self) -> String {
60        if self.args.is_empty() {
61            self.program.clone()
62        } else {
63            format!("{} {}", self.program, self.args.join(" "))
64        }
65    }
66}
67
68impl fmt::Display for DryRunResult {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        write!(f, "[DRY-RUN] {}", self.to_command_string())
71    }
72}
73
74/// Facade providing package manager operations with optional dry-run mode.
75#[derive(Debug, Clone)]
76pub struct PackageManagerFacade {
77    /// The package manager to use.
78    pkg_manager: PackageManager,
79    /// Whether to run in dry-run mode (log commands without executing).
80    dry_run: bool,
81    /// Whether to translate package names via mappings.
82    translate_packages: bool,
83}
84
85impl PackageManagerFacade {
86    /// Create a new facade for the given package manager.
87    pub fn new(pkg_manager: PackageManager) -> Self {
88        Self {
89            pkg_manager,
90            dry_run: false,
91            translate_packages: true,
92        }
93    }
94
95    /// Enable or disable dry-run mode.
96    pub fn with_dry_run(mut self, dry_run: bool) -> Self {
97        self.dry_run = dry_run;
98        self
99    }
100
101    /// Enable or disable package name translation.
102    pub fn with_translate_packages(mut self, translate: bool) -> Self {
103        self.translate_packages = translate;
104        self
105    }
106
107    /// Get the package manager type.
108    pub fn package_manager(&self) -> PackageManager {
109        self.pkg_manager
110    }
111
112    /// Check if dry-run mode is enabled.
113    pub fn is_dry_run(&self) -> bool {
114        self.dry_run
115    }
116
117    // =====================================================================
118    // Command Construction (public, testable without subprocesses)
119    // =====================================================================
120
121    /// Build the command for updating package indices.
122    ///
123    /// Returns the program and args (without sudo prefix).
124    pub fn build_update_indices_cmd(&self) -> (String, Vec<String>) {
125        match self.pkg_manager {
126            PackageManager::Apt => (
127                "apt".to_string(),
128                vec!["update".to_string(), "-qq".to_string()],
129            ),
130            PackageManager::Pacman => (
131                "pacman".to_string(),
132                vec!["-Sy".to_string(), "--noconfirm".to_string()],
133            ),
134            PackageManager::Dnf => ("dnf".to_string(), vec!["makecache".to_string()]),
135            PackageManager::Yum => ("yum".to_string(), vec!["makecache".to_string()]),
136            PackageManager::Zypper => ("zypper".to_string(), vec!["refresh".to_string()]),
137            PackageManager::Unknown => (
138                "echo".to_string(),
139                vec!["unknown package manager".to_string()],
140            ),
141        }
142    }
143
144    /// Build the command for upgrading all packages.
145    pub fn build_upgrade_cmd(&self) -> (String, Vec<String>) {
146        match self.pkg_manager {
147            PackageManager::Apt => (
148                "apt".to_string(),
149                vec!["upgrade".to_string(), "-y".to_string(), "-qq".to_string()],
150            ),
151            PackageManager::Pacman => (
152                "pacman".to_string(),
153                vec!["-Su".to_string(), "--noconfirm".to_string()],
154            ),
155            PackageManager::Dnf => (
156                "dnf".to_string(),
157                vec![
158                    "upgrade".to_string(),
159                    "-y".to_string(),
160                    "--refresh".to_string(),
161                ],
162            ),
163            PackageManager::Yum => (
164                "yum".to_string(),
165                vec!["upgrade".to_string(), "-y".to_string()],
166            ),
167            PackageManager::Zypper => (
168                "zypper".to_string(),
169                vec!["update".to_string(), "-y".to_string()],
170            ),
171            PackageManager::Unknown => (
172                "echo".to_string(),
173                vec!["unknown package manager".to_string()],
174            ),
175        }
176    }
177
178    /// Build the command for installing packages.
179    ///
180    /// Package names are translated if translation is enabled.
181    pub fn build_install_cmd(&self, packages: &[&str]) -> (String, Vec<String>) {
182        let native = self.translate_package_names(packages);
183        match self.pkg_manager {
184            PackageManager::Apt => (
185                "apt".to_string(),
186                vec!["install".to_string(), "-y".to_string(), "-qq".to_string()]
187                    .into_iter()
188                    .chain(native)
189                    .collect(),
190            ),
191            PackageManager::Pacman => (
192                "pacman".to_string(),
193                vec![
194                    "-S".to_string(),
195                    "--noconfirm".to_string(),
196                    "--needed".to_string(),
197                ]
198                .into_iter()
199                .chain(native)
200                .collect(),
201            ),
202            PackageManager::Dnf => (
203                "dnf".to_string(),
204                vec!["install".to_string(), "-y".to_string()]
205                    .into_iter()
206                    .chain(native)
207                    .collect(),
208            ),
209            PackageManager::Yum => (
210                "yum".to_string(),
211                vec!["install".to_string(), "-y".to_string()]
212                    .into_iter()
213                    .chain(native)
214                    .collect(),
215            ),
216            PackageManager::Zypper => (
217                "zypper".to_string(),
218                vec!["install".to_string(), "-y".to_string()]
219                    .into_iter()
220                    .chain(native)
221                    .collect(),
222            ),
223            PackageManager::Unknown => (
224                "echo".to_string(),
225                vec!["unknown package manager".to_string()],
226            ),
227        }
228    }
229
230    /// Build the command for removing packages.
231    pub fn build_remove_cmd(&self, packages: &[&str]) -> (String, Vec<String>) {
232        let native = self.translate_package_names(packages);
233        match self.pkg_manager {
234            PackageManager::Apt => (
235                "apt".to_string(),
236                vec!["remove".to_string(), "-y".to_string(), "-qq".to_string()]
237                    .into_iter()
238                    .chain(native)
239                    .collect(),
240            ),
241            PackageManager::Pacman => (
242                "pacman".to_string(),
243                vec!["-R".to_string(), "--noconfirm".to_string()]
244                    .into_iter()
245                    .chain(native)
246                    .collect(),
247            ),
248            PackageManager::Dnf => (
249                "dnf".to_string(),
250                vec!["remove".to_string(), "-y".to_string()]
251                    .into_iter()
252                    .chain(native)
253                    .collect(),
254            ),
255            PackageManager::Yum => (
256                "yum".to_string(),
257                vec!["remove".to_string(), "-y".to_string()]
258                    .into_iter()
259                    .chain(native)
260                    .collect(),
261            ),
262            PackageManager::Zypper => (
263                "zypper".to_string(),
264                vec!["remove".to_string(), "-y".to_string()]
265                    .into_iter()
266                    .chain(native)
267                    .collect(),
268            ),
269            PackageManager::Unknown => (
270                "echo".to_string(),
271                vec!["unknown package manager".to_string()],
272            ),
273        }
274    }
275
276    /// Build the command for purging packages (remove with config files).
277    pub fn build_purge_cmd(&self, packages: &[&str]) -> (String, Vec<String>) {
278        let native = self.translate_package_names(packages);
279        match self.pkg_manager {
280            PackageManager::Apt => (
281                "apt".to_string(),
282                vec!["purge".to_string(), "-y".to_string(), "-qq".to_string()]
283                    .into_iter()
284                    .chain(native)
285                    .collect(),
286            ),
287            PackageManager::Pacman => (
288                "pacman".to_string(),
289                vec!["-Rn".to_string(), "--noconfirm".to_string()]
290                    .into_iter()
291                    .chain(native)
292                    .collect(),
293            ),
294            // dnf/yum/zypper don't distinguish between remove and purge
295            PackageManager::Dnf => (
296                "dnf".to_string(),
297                vec!["remove".to_string(), "-y".to_string()]
298                    .into_iter()
299                    .chain(native)
300                    .collect(),
301            ),
302            PackageManager::Yum => (
303                "yum".to_string(),
304                vec!["remove".to_string(), "-y".to_string()]
305                    .into_iter()
306                    .chain(native)
307                    .collect(),
308            ),
309            PackageManager::Zypper => (
310                "zypper".to_string(),
311                vec!["remove".to_string(), "-y".to_string()]
312                    .into_iter()
313                    .chain(native)
314                    .collect(),
315            ),
316            PackageManager::Unknown => (
317                "echo".to_string(),
318                vec!["unknown package manager".to_string()],
319            ),
320        }
321    }
322
323    /// Build the command for checking if a package is installed.
324    ///
325    /// Returns the program and args for the query command.
326    /// The package name is translated if translation is enabled.
327    pub fn build_is_installed_cmd(&self, package: &str) -> (String, Vec<String>) {
328        let native = self.translate_single(package);
329        match self.pkg_manager {
330            PackageManager::Apt => ("dpkg".to_string(), vec!["-l".to_string(), native]),
331            PackageManager::Pacman => ("pacman".to_string(), vec!["-Qi".to_string(), native]),
332            PackageManager::Dnf | PackageManager::Yum | PackageManager::Zypper => {
333                ("rpm".to_string(), vec!["-q".to_string(), native])
334            }
335            PackageManager::Unknown => (
336                "echo".to_string(),
337                vec!["unknown package manager".to_string()],
338            ),
339        }
340    }
341
342    /// Build the command for searching packages.
343    pub fn build_search_cmd(&self, query: &str) -> (String, Vec<String>) {
344        match self.pkg_manager {
345            PackageManager::Apt => (
346                "apt".to_string(),
347                vec!["search".to_string(), query.to_string()],
348            ),
349            PackageManager::Pacman => (
350                "pacman".to_string(),
351                vec!["-Ss".to_string(), query.to_string()],
352            ),
353            PackageManager::Dnf => (
354                "dnf".to_string(),
355                vec!["search".to_string(), query.to_string()],
356            ),
357            PackageManager::Yum => (
358                "yum".to_string(),
359                vec!["search".to_string(), query.to_string()],
360            ),
361            PackageManager::Zypper => (
362                "zypper".to_string(),
363                vec!["search".to_string(), query.to_string()],
364            ),
365            PackageManager::Unknown => (
366                "echo".to_string(),
367                vec!["unknown package manager".to_string()],
368            ),
369        }
370    }
371
372    /// Build the command for showing package info.
373    pub fn build_info_cmd(&self, package: &str) -> (String, Vec<String>) {
374        let native = self.translate_single(package);
375        match self.pkg_manager {
376            PackageManager::Apt => ("apt".to_string(), vec!["show".to_string(), native]),
377            PackageManager::Pacman => ("pacman".to_string(), vec!["-Si".to_string(), native]),
378            PackageManager::Dnf => ("dnf".to_string(), vec!["info".to_string(), native]),
379            PackageManager::Yum => ("yum".to_string(), vec!["info".to_string(), native]),
380            PackageManager::Zypper => ("zypper".to_string(), vec!["info".to_string(), native]),
381            PackageManager::Unknown => (
382                "echo".to_string(),
383                vec!["unknown package manager".to_string()],
384            ),
385        }
386    }
387
388    // =====================================================================
389    // Execution Methods
390    // =====================================================================
391
392    /// Update package indices.
393    ///
394    /// In dry-run mode, returns a `DryRunResult` without executing.
395    pub fn update_indices(&self) -> Result<Option<DryRunResult>, anyhow::Error> {
396        let (program, args) = self.build_update_indices_cmd();
397        self.execute_with_sudo(&program, &args, "update package indices")
398    }
399
400    /// Install packages.
401    ///
402    /// In dry-run mode, returns a `DryRunResult` without executing.
403    pub fn install(&self, packages: &[&str]) -> Result<Option<DryRunResult>, anyhow::Error> {
404        if packages.is_empty() {
405            anyhow::bail!("No packages specified for install");
406        }
407        let (program, args) = self.build_install_cmd(packages);
408        let desc = format!("install packages: {}", packages.join(", "));
409        self.execute_with_sudo(&program, &args, &desc)
410    }
411
412    /// Remove packages.
413    pub fn remove(&self, packages: &[&str]) -> Result<Option<DryRunResult>, anyhow::Error> {
414        if packages.is_empty() {
415            anyhow::bail!("No packages specified for remove");
416        }
417        let (program, args) = self.build_remove_cmd(packages);
418        let desc = format!("remove packages: {}", packages.join(", "));
419        self.execute_with_sudo(&program, &args, &desc)
420    }
421
422    /// Purge packages (remove with configuration files).
423    pub fn purge(&self, packages: &[&str]) -> Result<Option<DryRunResult>, anyhow::Error> {
424        if packages.is_empty() {
425            anyhow::bail!("No packages specified for purge");
426        }
427        let (program, args) = self.build_purge_cmd(packages);
428        let desc = format!("purge packages: {}", packages.join(", "));
429        self.execute_with_sudo(&program, &args, &desc)
430    }
431
432    /// Check if a package is installed.
433    ///
434    /// Returns `Ok(true)` if installed, `Ok(false)` if not.
435    /// In dry-run mode, returns `Ok(false)` with the dry-run result.
436    pub fn is_installed(&self, package: &str) -> Result<bool, anyhow::Error> {
437        let (program, args) = self.build_is_installed_cmd(package);
438
439        if self.dry_run {
440            eprintln!("[DRY-RUN] {} {}", program, args.join(" "));
441            return Ok(false);
442        }
443
444        let status = Command::new(&program).args(&args).output()?;
445        Ok(status.status.success())
446    }
447
448    /// Search for packages.
449    pub fn search(&self, query: &str) -> Result<Option<String>, anyhow::Error> {
450        let (program, args) = self.build_search_cmd(query);
451
452        if self.dry_run {
453            eprintln!("[DRY-RUN] {} {}", program, args.join(" "));
454            return Ok(None);
455        }
456
457        let output = Command::new(&program).args(&args).output()?;
458        Ok(Some(String::from_utf8_lossy(&output.stdout).to_string()))
459    }
460
461    /// Get package info.
462    pub fn info(&self, package: &str) -> Result<Option<String>, anyhow::Error> {
463        let (program, args) = self.build_info_cmd(package);
464
465        if self.dry_run {
466            eprintln!("[DRY-RUN] {} {}", program, args.join(" "));
467            return Ok(None);
468        }
469
470        let output = Command::new(&program).args(&args).output()?;
471        Ok(Some(String::from_utf8_lossy(&output.stdout).to_string()))
472    }
473
474    // =====================================================================
475    // Internal Helpers
476    // =====================================================================
477
478    /// Translate package names if translation is enabled.
479    fn translate_package_names(&self, packages: &[&str]) -> Vec<String> {
480        if self.translate_packages {
481            super::package_mappings::map_package_names(packages, self.pkg_manager)
482        } else {
483            packages.iter().map(|s| s.to_string()).collect()
484        }
485    }
486
487    /// Translate a single package name if translation is enabled.
488    fn translate_single(&self, package: &str) -> String {
489        if self.translate_packages {
490            super::package_mappings::map_package_name(package, self.pkg_manager)
491        } else {
492            package.to_string()
493        }
494    }
495
496    /// Execute a command with sudo, or return a dry-run result.
497    fn execute_with_sudo(
498        &self,
499        program: &str,
500        args: &[String],
501        description: &str,
502    ) -> Result<Option<DryRunResult>, anyhow::Error> {
503        if self.dry_run {
504            let result = DryRunResult {
505                program: format!("sudo {}", program),
506                args: args.to_vec(),
507                description: description.to_string(),
508            };
509            eprintln!("{}", result);
510            return Ok(Some(result));
511        }
512
513        let status = Command::new("sudo")
514            .arg(program)
515            .args(args)
516            .env("DEBIAN_FRONTEND", "noninteractive")
517            .status()?;
518
519        if !status.success() {
520            anyhow::bail!("Failed to {}: exit code {:?}", description, status.code());
521        }
522
523        Ok(None)
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    // =====================================================================
532    // VAL-INFRA-005: Package manager operations correct
533    // =====================================================================
534
535    // --- Update Indices ---
536
537    #[test]
538    fn test_update_indices_apt() {
539        let pm = PackageManagerFacade::new(PackageManager::Apt);
540        let (prog, args) = pm.build_update_indices_cmd();
541        assert_eq!(prog, "apt");
542        assert!(args.contains(&"update".to_string()));
543    }
544
545    #[test]
546    fn test_update_indices_pacman() {
547        let pm = PackageManagerFacade::new(PackageManager::Pacman);
548        let (prog, args) = pm.build_update_indices_cmd();
549        assert_eq!(prog, "pacman");
550        assert!(args.contains(&"-Sy".to_string()));
551        assert!(args.contains(&"--noconfirm".to_string()));
552    }
553
554    #[test]
555    fn test_update_indices_dnf() {
556        let pm = PackageManagerFacade::new(PackageManager::Dnf);
557        let (prog, args) = pm.build_update_indices_cmd();
558        assert_eq!(prog, "dnf");
559        assert!(args.contains(&"makecache".to_string()));
560    }
561
562    #[test]
563    fn test_update_indices_yum() {
564        let pm = PackageManagerFacade::new(PackageManager::Yum);
565        let (prog, args) = pm.build_update_indices_cmd();
566        assert_eq!(prog, "yum");
567        assert!(args.contains(&"makecache".to_string()));
568    }
569
570    #[test]
571    fn test_update_indices_zypper() {
572        let pm = PackageManagerFacade::new(PackageManager::Zypper);
573        let (prog, args) = pm.build_update_indices_cmd();
574        assert_eq!(prog, "zypper");
575        assert!(args.contains(&"refresh".to_string()));
576    }
577
578    // --- Install ---
579
580    #[test]
581    fn test_install_apt() {
582        let pm = PackageManagerFacade::new(PackageManager::Apt);
583        let (prog, args) = pm.build_install_cmd(&["build-essential", "cmake"]);
584        assert_eq!(prog, "apt");
585        assert!(args.contains(&"install".to_string()));
586        assert!(args.contains(&"-y".to_string()));
587        assert!(args.contains(&"build-essential".to_string()));
588        assert!(args.contains(&"cmake".to_string()));
589    }
590
591    #[test]
592    fn test_install_pacman_with_translation() {
593        let pm = PackageManagerFacade::new(PackageManager::Pacman);
594        let (prog, args) = pm.build_install_cmd(&["build-essential"]);
595        assert_eq!(prog, "pacman");
596        assert!(args.contains(&"-S".to_string()));
597        assert!(args.contains(&"--noconfirm".to_string()));
598        assert!(args.contains(&"--needed".to_string()));
599        // build-essential → base-devel on pacman
600        assert!(args.contains(&"base-devel".to_string()));
601    }
602
603    #[test]
604    fn test_install_dnf() {
605        let pm = PackageManagerFacade::new(PackageManager::Dnf);
606        let (prog, args) = pm.build_install_cmd(&["cmake"]);
607        assert_eq!(prog, "dnf");
608        assert!(args.contains(&"install".to_string()));
609        assert!(args.contains(&"-y".to_string()));
610        assert!(args.contains(&"cmake".to_string()));
611    }
612
613    #[test]
614    fn test_install_yum() {
615        let pm = PackageManagerFacade::new(PackageManager::Yum);
616        let (prog, args) = pm.build_install_cmd(&["git"]);
617        assert_eq!(prog, "yum");
618        assert!(args.contains(&"install".to_string()));
619        assert!(args.contains(&"-y".to_string()));
620    }
621
622    #[test]
623    fn test_install_zypper() {
624        let pm = PackageManagerFacade::new(PackageManager::Zypper);
625        let (prog, args) = pm.build_install_cmd(&["git"]);
626        assert_eq!(prog, "zypper");
627        assert!(args.contains(&"install".to_string()));
628        assert!(args.contains(&"-y".to_string()));
629    }
630
631    // --- Remove ---
632
633    #[test]
634    fn test_remove_apt() {
635        let pm = PackageManagerFacade::new(PackageManager::Apt);
636        let (prog, args) = pm.build_remove_cmd(&["nano"]);
637        assert_eq!(prog, "apt");
638        assert!(args.contains(&"remove".to_string()));
639        assert!(args.contains(&"-y".to_string()));
640        assert!(args.contains(&"nano".to_string()));
641    }
642
643    #[test]
644    fn test_remove_pacman() {
645        let pm = PackageManagerFacade::new(PackageManager::Pacman);
646        let (prog, args) = pm.build_remove_cmd(&["nano"]);
647        assert_eq!(prog, "pacman");
648        assert!(args.contains(&"-R".to_string()));
649        assert!(args.contains(&"--noconfirm".to_string()));
650    }
651
652    #[test]
653    fn test_remove_dnf() {
654        let pm = PackageManagerFacade::new(PackageManager::Dnf);
655        let (prog, args) = pm.build_remove_cmd(&["nano"]);
656        assert_eq!(prog, "dnf");
657        assert!(args.contains(&"remove".to_string()));
658    }
659
660    // --- Purge ---
661
662    #[test]
663    fn test_purge_apt() {
664        let pm = PackageManagerFacade::new(PackageManager::Apt);
665        let (prog, args) = pm.build_purge_cmd(&["nginx"]);
666        assert_eq!(prog, "apt");
667        assert!(args.contains(&"purge".to_string()));
668    }
669
670    #[test]
671    fn test_purge_pacman() {
672        let pm = PackageManagerFacade::new(PackageManager::Pacman);
673        let (prog, args) = pm.build_purge_cmd(&["nginx"]);
674        assert_eq!(prog, "pacman");
675        assert!(args.contains(&"-Rn".to_string()));
676    }
677
678    #[test]
679    fn test_purge_dnf_uses_remove() {
680        let pm = PackageManagerFacade::new(PackageManager::Dnf);
681        let (prog, args) = pm.build_purge_cmd(&["nginx"]);
682        assert_eq!(prog, "dnf");
683        assert!(args.contains(&"remove".to_string()));
684    }
685
686    // --- Is Installed ---
687
688    #[test]
689    fn test_is_installed_apt() {
690        let pm = PackageManagerFacade::new(PackageManager::Apt);
691        let (prog, args) = pm.build_is_installed_cmd("curl");
692        assert_eq!(prog, "dpkg");
693        assert!(args.contains(&"-l".to_string()));
694        assert!(args.contains(&"curl".to_string()));
695    }
696
697    #[test]
698    fn test_is_installed_pacman() {
699        let pm = PackageManagerFacade::new(PackageManager::Pacman);
700        let (prog, args) = pm.build_is_installed_cmd("curl");
701        assert_eq!(prog, "pacman");
702        assert!(args.contains(&"-Qi".to_string()));
703        assert!(args.contains(&"curl".to_string()));
704    }
705
706    #[test]
707    fn test_is_installed_dnf() {
708        let pm = PackageManagerFacade::new(PackageManager::Dnf);
709        let (prog, args) = pm.build_is_installed_cmd("curl");
710        assert_eq!(prog, "rpm");
711        assert!(args.contains(&"-q".to_string()));
712    }
713
714    #[test]
715    fn test_is_installed_yum() {
716        let pm = PackageManagerFacade::new(PackageManager::Yum);
717        let (prog, _args) = pm.build_is_installed_cmd("curl");
718        assert_eq!(prog, "rpm");
719    }
720
721    #[test]
722    fn test_is_installed_zypper() {
723        let pm = PackageManagerFacade::new(PackageManager::Zypper);
724        let (prog, _args) = pm.build_is_installed_cmd("curl");
725        assert_eq!(prog, "rpm");
726    }
727
728    // --- Search ---
729
730    #[test]
731    fn test_search_apt() {
732        let pm = PackageManagerFacade::new(PackageManager::Apt);
733        let (prog, args) = pm.build_search_cmd("python");
734        assert_eq!(prog, "apt");
735        assert!(args.contains(&"search".to_string()));
736        assert!(args.contains(&"python".to_string()));
737    }
738
739    #[test]
740    fn test_search_pacman() {
741        let pm = PackageManagerFacade::new(PackageManager::Pacman);
742        let (prog, args) = pm.build_search_cmd("python");
743        assert_eq!(prog, "pacman");
744        assert!(args.contains(&"-Ss".to_string()));
745    }
746
747    // --- Info ---
748
749    #[test]
750    fn test_info_apt() {
751        let pm = PackageManagerFacade::new(PackageManager::Apt);
752        let (prog, args) = pm.build_info_cmd("python3");
753        assert_eq!(prog, "apt");
754        assert!(args.contains(&"show".to_string()));
755    }
756
757    #[test]
758    fn test_info_pacman() {
759        let pm = PackageManagerFacade::new(PackageManager::Pacman);
760        let (prog, args) = pm.build_info_cmd("python3");
761        assert_eq!(prog, "pacman");
762        assert!(args.contains(&"-Si".to_string()));
763    }
764
765    #[test]
766    fn test_info_dnf() {
767        let pm = PackageManagerFacade::new(PackageManager::Dnf);
768        let (prog, args) = pm.build_info_cmd("python3");
769        assert_eq!(prog, "dnf");
770        assert!(args.contains(&"info".to_string()));
771    }
772
773    // --- Upgrade ---
774
775    #[test]
776    fn test_upgrade_apt() {
777        let pm = PackageManagerFacade::new(PackageManager::Apt);
778        let (prog, args) = pm.build_upgrade_cmd();
779        assert_eq!(prog, "apt");
780        assert!(args.contains(&"upgrade".to_string()));
781        assert!(args.contains(&"-y".to_string()));
782    }
783
784    #[test]
785    fn test_upgrade_pacman() {
786        let pm = PackageManagerFacade::new(PackageManager::Pacman);
787        let (prog, args) = pm.build_upgrade_cmd();
788        assert_eq!(prog, "pacman");
789        assert!(args.contains(&"-Su".to_string()));
790    }
791
792    // --- Translation in commands ---
793
794    #[test]
795    fn test_install_translates_package_names() {
796        let pm = PackageManagerFacade::new(PackageManager::Pacman);
797        let (_, args) = pm.build_install_cmd(&["build-essential", "python3-dev"]);
798        // build-essential → base-devel, python3-dev → python
799        assert!(args.contains(&"base-devel".to_string()));
800        assert!(args.contains(&"python".to_string()));
801    }
802
803    #[test]
804    fn test_install_no_translation() {
805        let pm = PackageManagerFacade::new(PackageManager::Pacman).with_translate_packages(false);
806        let (_, args) = pm.build_install_cmd(&["build-essential"]);
807        // Should use original name
808        assert!(args.contains(&"build-essential".to_string()));
809        assert!(!args.contains(&"base-devel".to_string()));
810    }
811
812    // --- Unknown package manager ---
813
814    #[test]
815    fn test_unknown_pm_install() {
816        let pm = PackageManagerFacade::new(PackageManager::Unknown);
817        let (prog, args) = pm.build_install_cmd(&["git"]);
818        assert_eq!(prog, "echo");
819        assert!(args.contains(&"unknown package manager".to_string()));
820    }
821
822    // =====================================================================
823    // VAL-INFRA-006: Package manager supports dry-run mode
824    // =====================================================================
825
826    #[test]
827    fn test_dry_run_install_returns_result_without_executing() {
828        let pm = PackageManagerFacade::new(PackageManager::Apt).with_dry_run(true);
829        let result = pm.install(&["git", "cmake"]).unwrap();
830        assert!(result.is_some());
831        let dry = result.unwrap();
832        assert!(dry.to_command_string().contains("sudo apt"));
833        assert!(dry.to_command_string().contains("install"));
834        assert!(dry.description.contains("git"));
835        assert!(dry.description.contains("cmake"));
836    }
837
838    #[test]
839    fn test_dry_run_update_indices() {
840        let pm = PackageManagerFacade::new(PackageManager::Apt).with_dry_run(true);
841        let result = pm.update_indices().unwrap();
842        assert!(result.is_some());
843        let dry = result.unwrap();
844        assert!(dry.to_command_string().contains("sudo apt update"));
845    }
846
847    #[test]
848    fn test_dry_run_remove() {
849        let pm = PackageManagerFacade::new(PackageManager::Pacman).with_dry_run(true);
850        let result = pm.remove(&["nano"]).unwrap();
851        assert!(result.is_some());
852        let dry = result.unwrap();
853        assert!(dry.to_command_string().contains("sudo pacman"));
854        assert!(dry.to_command_string().contains("-R"));
855    }
856
857    #[test]
858    fn test_dry_run_is_installed_returns_false() {
859        let pm = PackageManagerFacade::new(PackageManager::Apt).with_dry_run(true);
860        // Even if the package is installed, dry-run should return false
861        let result = pm.is_installed("bash").unwrap();
862        assert!(!result);
863    }
864
865    #[test]
866    fn test_dry_run_search_returns_none() {
867        let pm = PackageManagerFacade::new(PackageManager::Apt).with_dry_run(true);
868        let result = pm.search("python").unwrap();
869        assert!(result.is_none());
870    }
871
872    #[test]
873    fn test_dry_run_info_returns_none() {
874        let pm = PackageManagerFacade::new(PackageManager::Apt).with_dry_run(true);
875        let result = pm.info("python3").unwrap();
876        assert!(result.is_none());
877    }
878
879    #[test]
880    fn test_dry_run_display_format() {
881        let pm = PackageManagerFacade::new(PackageManager::Apt).with_dry_run(true);
882        let result = pm.install(&["git"]).unwrap().unwrap();
883        let display = format!("{}", result);
884        assert!(display.starts_with("[DRY-RUN]"));
885        assert!(display.contains("sudo apt"));
886    }
887
888    #[test]
889    fn test_dry_run_command_string_format() {
890        let dry = DryRunResult {
891            program: "sudo apt".to_string(),
892            args: vec!["install".to_string(), "-y".to_string(), "git".to_string()],
893            description: "install git".to_string(),
894        };
895        assert_eq!(dry.to_command_string(), "sudo apt install -y git");
896    }
897
898    // --- Error cases ---
899
900    #[test]
901    fn test_install_empty_packages_errors() {
902        let pm = PackageManagerFacade::new(PackageManager::Apt);
903        let result = pm.install(&[]);
904        assert!(result.is_err());
905    }
906
907    #[test]
908    fn test_remove_empty_packages_errors() {
909        let pm = PackageManagerFacade::new(PackageManager::Apt);
910        let result = pm.remove(&[]);
911        assert!(result.is_err());
912    }
913
914    #[test]
915    fn test_purge_empty_packages_errors() {
916        let pm = PackageManagerFacade::new(PackageManager::Apt);
917        let result = pm.purge(&[]);
918        assert!(result.is_err());
919    }
920
921    // --- PackageOperation Display ---
922
923    #[test]
924    fn test_package_operation_display() {
925        assert_eq!(format!("{}", PackageOperation::Install), "install");
926        assert_eq!(format!("{}", PackageOperation::Remove), "remove");
927        assert_eq!(format!("{}", PackageOperation::Purge), "purge");
928        assert_eq!(format!("{}", PackageOperation::Update), "update");
929        assert_eq!(format!("{}", PackageOperation::Upgrade), "upgrade");
930        assert_eq!(format!("{}", PackageOperation::Search), "search");
931        assert_eq!(format!("{}", PackageOperation::Info), "info");
932        assert_eq!(format!("{}", PackageOperation::IsInstalled), "is_installed");
933        assert_eq!(
934            format!("{}", PackageOperation::ListInstalled),
935            "list_installed"
936        );
937    }
938
939    // --- Accessors ---
940
941    #[test]
942    fn test_accessors() {
943        let pm = PackageManagerFacade::new(PackageManager::Dnf).with_dry_run(true);
944        assert_eq!(pm.package_manager(), PackageManager::Dnf);
945        assert!(pm.is_dry_run());
946    }
947
948    #[test]
949    fn test_dry_run_default_false() {
950        let pm = PackageManagerFacade::new(PackageManager::Apt);
951        assert!(!pm.is_dry_run());
952    }
953}