Skip to main content

jarvy/tools/
common.rs

1#![allow(dead_code)] // Public API for tool installation utilities
2
3use std::process::{Command, Output};
4use std::sync::OnceLock;
5
6use crate::network::config::NetworkConfig;
7use crate::network::propagate::apply_network_config;
8
9#[derive(thiserror::Error, Debug)]
10pub enum InstallError {
11    #[error("unsupported platform")]
12    Unsupported,
13    #[error("prerequisite missing: {0}")]
14    Prereq(&'static str),
15    #[error("invalid permissions: {0}")]
16    InvalidPermissions(&'static str),
17    #[error("command failed: {cmd} (code: {code:?})\n{stderr}")]
18    CommandFailed {
19        cmd: String,
20        code: Option<i32>,
21        stderr: String,
22    },
23    #[error("io error: {0}")]
24    Io(#[from] std::io::Error),
25    #[error("parse error: {0}")]
26    Parse(&'static str),
27}
28
29// OS enum for config keys and runtime resolution
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize)]
31#[serde(rename_all = "lowercase")]
32pub enum Os {
33    Linux,
34    Macos,
35    Windows,
36    Bsd,
37}
38
39// Determine the current OS as our enum
40pub fn current_os() -> Os {
41    #[cfg(target_os = "linux")]
42    {
43        Os::Linux
44    }
45    #[cfg(target_os = "macos")]
46    {
47        Os::Macos
48    }
49    #[cfg(target_os = "windows")]
50    {
51        Os::Windows
52    }
53    #[cfg(target_os = "freebsd")]
54    {
55        Os::Bsd
56    }
57}
58
59// Global default for whether to use sudo on POSIX installs. Can be set from Config in main.
60// None means: auto-detect per operation (try without sudo, then with if available).
61static USE_SUDO_DEFAULT: OnceLock<Option<bool>> = OnceLock::new();
62
63pub fn set_default_use_sudo(val: Option<bool>) {
64    let _ = USE_SUDO_DEFAULT.set(val);
65}
66
67pub fn default_use_sudo() -> Option<bool> {
68    if let Some(v) = USE_SUDO_DEFAULT.get() {
69        *v
70    } else {
71        // Unset -> auto mode
72        None
73    }
74}
75
76/// Spawn a command, capturing its output, and emit a structured warning +
77/// telemetry counter on failure. Replaces the duplicated
78/// `match Command::new(...).output() { Err(e) => { eprintln!(...); return; } }`
79/// pattern that proliferated in setup/provisioner during the panic-removal
80/// sweep.
81///
82/// Returns `None` on spawn failure so callers can keep the
83/// `let Some(out) = run_capture(...) else { return; };` shape that mirrors
84/// the prior eprintln+return idiom but routes through tracing.
85///
86/// `stage` should be a bounded label (e.g. `"hard_dep_check"`,
87/// `"macos_setup"`) — used as a low-cardinality telemetry attribute.
88pub fn run_capture(cmd: &str, args: &[&str], stage: &str, context: &str) -> Option<Output> {
89    match Command::new(cmd).args(args).output() {
90        Ok(out) => Some(out),
91        Err(e) => {
92            tracing::warn!(
93                event = "setup.subprocess.failed",
94                stage = %stage,
95                command = %cmd,
96                context = %context,
97                error = %e,
98            );
99            eprintln!("{context}: {e}");
100            None
101        }
102    }
103}
104
105#[must_use = "this Result may contain an error that should be handled"]
106pub fn run(cmd: &str, args: &[&str]) -> Result<Output, InstallError> {
107    // Fast, deterministic tests: allow skipping external command execution.
108    // Integration tests can opt-in via JARVY_FAST_TEST; unit tests default to skip unless explicitly overridden.
109    if std::env::var_os("JARVY_FAST_TEST").is_some() {
110        return Err(InstallError::Prereq(
111            "skipped external command in fast test mode",
112        ));
113    }
114    #[cfg(test)]
115    {
116        if std::env::var_os("JARVY_RUN_EXTERNAL_CMDS_IN_TEST").is_none() {
117            return Err(InstallError::Prereq(
118                "external commands disabled during unit tests",
119            ));
120        }
121    }
122
123    let out = Command::new(cmd).args(args).output().map_err(|e| {
124        use std::io::ErrorKind::*;
125        match e.kind() {
126            NotFound => InstallError::Prereq("required command not found on PATH"),
127            PermissionDenied => {
128                InstallError::InvalidPermissions("operation requires elevated privileges")
129            }
130            _ => InstallError::Io(e),
131        }
132    })?;
133
134    if !out.status.success() {
135        // Try to capture stderr for easier diagnostics.
136        return Err(InstallError::CommandFailed {
137            cmd: cmd.to_string(),
138            code: out.status.code(),
139            stderr: String::from_utf8_lossy(&out.stderr).into(),
140        });
141    }
142    Ok(out)
143}
144
145/// Run a command with network/proxy configuration applied.
146///
147/// This variant applies HTTP_PROXY, HTTPS_PROXY, NO_PROXY, and CA bundle
148/// environment variables to the spawned process based on the NetworkConfig.
149#[must_use = "this Result may contain an error that should be handled"]
150pub fn run_with_network(
151    cmd: &str,
152    args: &[&str],
153    network: Option<&NetworkConfig>,
154    tool_name: &str,
155) -> Result<Output, InstallError> {
156    // Fast, deterministic tests: allow skipping external command execution.
157    if std::env::var_os("JARVY_FAST_TEST").is_some() {
158        return Err(InstallError::Prereq(
159            "skipped external command in fast test mode",
160        ));
161    }
162    #[cfg(test)]
163    {
164        if std::env::var_os("JARVY_RUN_EXTERNAL_CMDS_IN_TEST").is_none() {
165            return Err(InstallError::Prereq(
166                "external commands disabled during unit tests",
167            ));
168        }
169    }
170
171    let mut command = Command::new(cmd);
172    command.args(args);
173
174    // Apply network/proxy configuration if provided
175    if let Some(net_config) = network {
176        apply_network_config(&mut command, net_config, tool_name);
177    }
178
179    let out = command.output().map_err(|e| {
180        use std::io::ErrorKind::*;
181        match e.kind() {
182            NotFound => InstallError::Prereq("required command not found on PATH"),
183            PermissionDenied => {
184                InstallError::InvalidPermissions("operation requires elevated privileges")
185            }
186            _ => InstallError::Io(e),
187        }
188    })?;
189
190    if !out.status.success() {
191        return Err(InstallError::CommandFailed {
192            cmd: cmd.to_string(),
193            code: out.status.code(),
194            stderr: String::from_utf8_lossy(&out.stderr).into(),
195        });
196    }
197    Ok(out)
198}
199
200/// Run a command, prefixing with sudo if configured and applicable (non-Windows)
201#[must_use = "this Result may contain an error that should be handled"]
202pub fn run_maybe_sudo(use_sudo: bool, cmd: &str, args: &[&str]) -> Result<Output, InstallError> {
203    match current_os() {
204        Os::Windows => run(cmd, args),
205        Os::Linux | Os::Macos | Os::Bsd => {
206            if use_sudo {
207                // sudo <cmd> <args...>
208                let mut all = Vec::with_capacity(1 + args.len());
209                all.push(cmd);
210                all.extend_from_slice(args);
211                run("sudo", &all)
212            } else {
213                run(cmd, args)
214            }
215        }
216    }
217}
218
219/// Run a command with sudo and network/proxy configuration.
220///
221/// Combines sudo elevation with proxy settings propagation.
222#[must_use = "this Result may contain an error that should be handled"]
223pub fn run_maybe_sudo_with_network(
224    use_sudo: bool,
225    cmd: &str,
226    args: &[&str],
227    network: Option<&NetworkConfig>,
228    tool_name: &str,
229) -> Result<Output, InstallError> {
230    match current_os() {
231        Os::Windows => run_with_network(cmd, args, network, tool_name),
232        Os::Linux | Os::Macos | Os::Bsd => {
233            if use_sudo {
234                // sudo -E preserves environment (including proxy vars)
235                // sudo <cmd> <args...>
236                let mut all = Vec::with_capacity(2 + args.len());
237                all.push("-E"); // Preserve environment
238                all.push(cmd);
239                all.extend_from_slice(args);
240                run_with_network("sudo", &all, network, tool_name)
241            } else {
242                run_with_network(cmd, args, network, tool_name)
243            }
244        }
245    }
246}
247
248pub fn has(cmd: &str) -> bool {
249    Command::new(cmd)
250        .arg("--version")
251        .output()
252        .map(|o| o.status.success())
253        .unwrap_or(false)
254}
255
256// Require a single command to exist on PATH, otherwise return a Prereq error with remediation.
257pub fn require(cmd: &str, remediation: &'static str) -> Result<(), InstallError> {
258    if has(cmd) {
259        Ok(())
260    } else {
261        Err(InstallError::Prereq(remediation))
262    }
263}
264
265// Require one of multiple candidates (e.g., apt or apt-get)
266pub fn require_any<'a>(
267    candidates: &[&'a str],
268    remediation: &'static str,
269) -> Result<&'a str, InstallError> {
270    for c in candidates {
271        if has(c) {
272            return Ok(*c);
273        }
274    }
275    Err(InstallError::Prereq(remediation))
276}
277
278/// Check if a command's version satisfies the given requirement.
279///
280/// Uses proper semantic versioning comparison instead of substring matching.
281/// Supports requirements like:
282/// - `"latest"` or `"*"`: Always passes
283/// - `"3.10"`: Matches 3.10.x
284/// - `"3.10.0"`: Exact match
285/// - `">= 3.10"`: Minimum version
286/// - `">= 3.10, < 4.0"`: Range expression
287pub fn cmd_satisfies(cmd: &str, requirement: &str) -> bool {
288    if let Ok(out) = Command::new(cmd).arg("--version").output() {
289        let version_output = String::from_utf8_lossy(&out.stdout);
290        return super::version::version_satisfies(&version_output, requirement);
291    }
292    false
293}
294
295pub fn plan_sudo_attempts(use_sudo: Option<bool>, sudo_available: bool) -> Vec<bool> {
296    match use_sudo {
297        Some(flag) => vec![flag],
298        None => {
299            if sudo_available {
300                vec![false, true]
301            } else {
302                vec![false]
303            }
304        }
305    }
306}
307
308#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
309pub enum PackageManager {
310    Apt,
311    Dnf,
312    Yum,
313    Zypper,
314    Pacman,
315    Apk,
316    Brew,
317    BrewCask, // Homebrew casks (GUI apps)
318    Winget,
319    Choco,
320    Pkg, // FreeBSD pkg
321}
322
323#[cfg(target_os = "linux")]
324pub fn detect_linux_pm() -> Option<PackageManager> {
325    use std::{fs, process::Command};
326    let has = |c| {
327        Command::new(c)
328            .arg("--version")
329            .output()
330            .map(|o| o.status.success())
331            .unwrap_or(false)
332    };
333
334    // (Optional) use /etc/os-release to bias choices when you need vendor repos
335    // ID / ID_LIKE fields are the standard signals.  [oai_citation:0‡Freedesktop](https://www.freedesktop.org/software/systemd/man/os-release.html?utm_source=chatgpt.com) [oai_citation:1‡Debian Manpages](https://manpages.debian.org/trixie/systemd/os-release.5.en.html?utm_source=chatgpt.com)
336    let _os_release = fs::read_to_string("/etc/os-release").unwrap_or_default();
337
338    if has("apt-get") || has("apt") {
339        return Some(PackageManager::Apt);
340    }
341    if has("dnf") {
342        return Some(PackageManager::Dnf);
343    }
344    if has("yum") {
345        return Some(PackageManager::Yum);
346    }
347    if has("zypper") {
348        return Some(PackageManager::Zypper);
349    }
350    if has("pacman") {
351        return Some(PackageManager::Pacman);
352    }
353    if has("apk") {
354        return Some(PackageManager::Apk);
355    }
356    None
357}
358
359#[cfg(target_os = "freebsd")]
360pub fn detect_bsd_pm() -> Option<PackageManager> {
361    use std::process::Command;
362    let has = |c| {
363        Command::new(c)
364            .arg("--version")
365            .output()
366            .map(|o| o.status.success())
367            .unwrap_or(false)
368    };
369
370    if has("pkg") {
371        return Some(PackageManager::Pkg);
372    }
373    None
374}
375
376#[cfg(test)]
377mod sudo_plan_tests {
378    use super::plan_sudo_attempts;
379
380    #[test]
381    fn plan_some_true_only_true() {
382        let v = plan_sudo_attempts(Some(true), true);
383        assert_eq!(v, vec![true]);
384    }
385
386    #[test]
387    fn plan_some_false_only_false() {
388        let v = plan_sudo_attempts(Some(false), true);
389        assert_eq!(v, vec![false]);
390    }
391
392    #[test]
393    fn plan_none_with_sudo_available() {
394        let v = plan_sudo_attempts(None, true);
395        assert_eq!(v, vec![false, true]);
396    }
397
398    #[test]
399    fn plan_none_without_sudo_available() {
400        let v = plan_sudo_attempts(None, false);
401        assert_eq!(v, vec![false]);
402    }
403}
404
405#[cfg(test)]
406mod batch_install_tests {
407    use super::*;
408
409    #[test]
410    fn empty_list_returns_empty_result() {
411        let result = PkgOps::batch_install(PackageManager::Brew, &[], None);
412        assert!(result.is_ok());
413        let result = result.unwrap();
414        assert!(result.succeeded.is_empty());
415        assert!(result.failed.is_empty());
416    }
417
418    #[test]
419    fn batch_install_result_default() {
420        let result = BatchInstallResult {
421            succeeded: vec!["foo".to_string()],
422            failed: vec![("bar".to_string(), "error".to_string())],
423        };
424        assert_eq!(result.succeeded.len(), 1);
425        assert_eq!(result.failed.len(), 1);
426        assert_eq!(result.succeeded[0], "foo");
427        assert_eq!(result.failed[0].0, "bar");
428    }
429
430    #[test]
431    fn package_manager_has_required_traits() {
432        // Test that PackageManager can be used as HashMap key
433        let mut map = std::collections::HashMap::new();
434        map.insert(PackageManager::Brew, vec!["jq", "ripgrep"]);
435        map.insert(PackageManager::Apt, vec!["git", "curl"]);
436        assert_eq!(map.len(), 2);
437        assert!(map.contains_key(&PackageManager::Brew));
438        assert!(map.contains_key(&PackageManager::Apt));
439    }
440
441    #[test]
442    fn package_manager_equality() {
443        assert_eq!(PackageManager::Brew, PackageManager::Brew);
444        assert_ne!(PackageManager::Brew, PackageManager::Apt);
445        assert_eq!(PackageManager::BrewCask, PackageManager::BrewCask);
446        assert_ne!(PackageManager::Winget, PackageManager::Choco);
447    }
448}
449
450#[allow(dead_code)]
451pub struct PkgOps {
452    name: &'static str,
453}
454
455/// Result of a batch installation operation.
456#[derive(Debug)]
457pub struct BatchInstallResult {
458    /// Packages that were successfully installed
459    pub succeeded: Vec<String>,
460    /// Packages that failed to install (package name, error message)
461    pub failed: Vec<(String, String)>,
462}
463
464impl PkgOps {
465    /// Install multiple packages in a single batch operation.
466    ///
467    /// This is more efficient than installing packages one-by-one because:
468    /// - Single dependency resolution pass
469    /// - Single lock acquisition
470    /// - Package manager can optimize internally
471    ///
472    /// Returns a BatchInstallResult indicating which packages succeeded/failed.
473    /// On batch failure, individual packages are retried.
474    pub fn batch_install(
475        pm: PackageManager,
476        packages: &[&str],
477        use_sudo: Option<bool>,
478    ) -> Result<BatchInstallResult, InstallError> {
479        if packages.is_empty() {
480            return Ok(BatchInstallResult {
481                succeeded: vec![],
482                failed: vec![],
483            });
484        }
485
486        // Single package: use regular install
487        if packages.len() == 1 {
488            match Self::install(pm, packages[0], use_sudo) {
489                Ok(()) => {
490                    return Ok(BatchInstallResult {
491                        succeeded: vec![packages[0].to_string()],
492                        failed: vec![],
493                    });
494                }
495                Err(e) => {
496                    return Ok(BatchInstallResult {
497                        succeeded: vec![],
498                        failed: vec![(packages[0].to_string(), format!("{}", e))],
499                    });
500                }
501            }
502        }
503
504        // Try batch install first
505        let batch_result = Self::try_batch_install(pm, packages, use_sudo);
506
507        match batch_result {
508            Ok(()) => {
509                // All packages installed successfully
510                Ok(BatchInstallResult {
511                    succeeded: packages.iter().map(|s| s.to_string()).collect(),
512                    failed: vec![],
513                })
514            }
515            Err(_) => {
516                // Batch failed, retry individually to find which packages failed
517                let mut succeeded = Vec::with_capacity(packages.len());
518                let mut failed = Vec::with_capacity(packages.len());
519
520                for pkg in packages {
521                    match Self::install(pm, pkg, use_sudo) {
522                        Ok(()) => succeeded.push(pkg.to_string()),
523                        Err(e) => failed.push((pkg.to_string(), format!("{}", e))),
524                    }
525                }
526
527                Ok(BatchInstallResult { succeeded, failed })
528            }
529        }
530    }
531
532    /// Attempt to install multiple packages in a single command.
533    /// Returns Ok if all packages installed, Err if the command failed.
534    fn try_batch_install(
535        pm: PackageManager,
536        packages: &[&str],
537        use_sudo: Option<bool>,
538    ) -> Result<(), InstallError> {
539        match pm {
540            PackageManager::Apt => {
541                let apt = require_any(&["apt-get", "apt"], "apt is required to install packages")?;
542                let mut args = vec!["install", "-y"];
543                args.extend(packages);
544                Self::run_with_sudo_strategy(use_sudo, apt, &args)
545            }
546            PackageManager::Dnf => {
547                require("dnf", "dnf is required to install packages")?;
548                let mut args = vec!["install", "-y"];
549                args.extend(packages);
550                Self::run_with_sudo_strategy(use_sudo, "dnf", &args)
551            }
552            PackageManager::Yum => {
553                require("yum", "yum is required to install packages")?;
554                let mut args = vec!["install", "-y"];
555                args.extend(packages);
556                Self::run_with_sudo_strategy(use_sudo, "yum", &args)
557            }
558            PackageManager::Zypper => {
559                require("zypper", "zypper is required to install packages")?;
560                let mut args = vec!["--non-interactive", "install", "--no-confirm"];
561                args.extend(packages);
562                Self::run_with_sudo_strategy(use_sudo, "zypper", &args)
563            }
564            PackageManager::Pacman => {
565                require("pacman", "pacman is required to install packages")?;
566                let mut args = vec!["--noconfirm", "-S"];
567                args.extend(packages);
568                Self::run_with_sudo_strategy(use_sudo, "pacman", &args)
569            }
570            PackageManager::Apk => {
571                require("apk", "apk is required to install packages")?;
572                let mut args = vec!["add"];
573                args.extend(packages);
574                Self::run_with_sudo_strategy(use_sudo, "apk", &args)
575            }
576            PackageManager::Brew => {
577                require("brew", "Homebrew is required to install packages")?;
578                let mut args = vec!["install"];
579                args.extend(packages);
580                run("brew", &args)?;
581                Ok(())
582            }
583            PackageManager::Winget => {
584                // winget doesn't support true batch install, but we can chain commands
585                // For now, install sequentially since winget is internally sequential anyway
586                require("winget", "Winget is required to install packages")?;
587                for pkg in packages {
588                    run("winget", &["install", "-e", "--id", pkg])?;
589                }
590                Ok(())
591            }
592            PackageManager::BrewCask => {
593                require("brew", "Homebrew is required to install casks")?;
594                let mut args = vec!["install", "--cask"];
595                args.extend(packages);
596                run("brew", &args)?;
597                Ok(())
598            }
599            PackageManager::Choco => {
600                require("choco", "Chocolatey is required to install packages")?;
601                let mut args = vec!["install", "-y"];
602                args.extend(packages);
603                run("choco", &args)?;
604                Ok(())
605            }
606            PackageManager::Pkg => {
607                require("pkg", "FreeBSD pkg is required to install packages")?;
608                let mut args = vec!["install", "-y"];
609                args.extend(packages);
610                Self::run_with_sudo_strategy(use_sudo, "pkg", &args)
611            }
612        }
613    }
614
615    /// Helper to run a command with sudo fallback strategy.
616    fn run_with_sudo_strategy(
617        use_sudo: Option<bool>,
618        cmd: &str,
619        args: &[&str],
620    ) -> Result<(), InstallError> {
621        match use_sudo {
622            Some(flag) => {
623                if flag {
624                    require("sudo", "sudo is required to install packages")?;
625                }
626                run_maybe_sudo(flag, cmd, args)?;
627                Ok(())
628            }
629            None => {
630                if let Err(e) = run_maybe_sudo(false, cmd, args) {
631                    if has("sudo") {
632                        run_maybe_sudo(true, cmd, args)?;
633                        Ok(())
634                    } else {
635                        Err(e)
636                    }
637                } else {
638                    Ok(())
639                }
640            }
641        }
642    }
643
644    /// Install packages using Homebrew cask (for GUI apps) in batch.
645    pub fn batch_install_cask(packages: &[&str]) -> Result<BatchInstallResult, InstallError> {
646        if packages.is_empty() {
647            return Ok(BatchInstallResult {
648                succeeded: vec![],
649                failed: vec![],
650            });
651        }
652
653        require("brew", "Homebrew is required to install casks")?;
654
655        let mut args = vec!["install", "--cask"];
656        args.extend(packages);
657
658        match run("brew", &args) {
659            Ok(_) => Ok(BatchInstallResult {
660                succeeded: packages.iter().map(|s| s.to_string()).collect(),
661                failed: vec![],
662            }),
663            Err(_) => {
664                // Retry individually
665                let mut succeeded = Vec::with_capacity(packages.len());
666                let mut failed = Vec::with_capacity(packages.len());
667                for pkg in packages {
668                    match run("brew", &["install", "--cask", pkg]) {
669                        Ok(_) => succeeded.push(pkg.to_string()),
670                        Err(e) => failed.push((pkg.to_string(), format!("{}", e))),
671                    }
672                }
673                Ok(BatchInstallResult { succeeded, failed })
674            }
675        }
676    }
677
678    /// Install packages using Chocolatey in batch.
679    pub fn batch_install_choco(packages: &[&str]) -> Result<BatchInstallResult, InstallError> {
680        if packages.is_empty() {
681            return Ok(BatchInstallResult {
682                succeeded: vec![],
683                failed: vec![],
684            });
685        }
686
687        require("choco", "Chocolatey is required to install packages")?;
688
689        let mut args = vec!["install", "-y"];
690        args.extend(packages);
691
692        match run("choco", &args) {
693            Ok(_) => Ok(BatchInstallResult {
694                succeeded: packages.iter().map(|s| s.to_string()).collect(),
695                failed: vec![],
696            }),
697            Err(_) => {
698                // Retry individually
699                let mut succeeded = Vec::with_capacity(packages.len());
700                let mut failed = Vec::with_capacity(packages.len());
701                for pkg in packages {
702                    match run("choco", &["install", "-y", pkg]) {
703                        Ok(_) => succeeded.push(pkg.to_string()),
704                        Err(e) => failed.push((pkg.to_string(), format!("{}", e))),
705                    }
706                }
707                Ok(BatchInstallResult { succeeded, failed })
708            }
709        }
710    }
711
712    pub fn update(pm: PackageManager, use_sudo: Option<bool>) -> Result<(), InstallError> {
713        match pm {
714            PackageManager::Apt => {
715                // Ensure prerequisites exist before attempting the update
716                let apt = require_any(&["apt-get", "apt"], "apt is required to update packages")?;
717                // Decide sudo strategy
718                match use_sudo {
719                    Some(flag) => {
720                        if flag {
721                            require("sudo", "sudo is required to update packages")?;
722                        }
723                        run_maybe_sudo(flag, apt, &["update"])?;
724                    }
725                    None => {
726                        // Try without sudo first
727                        if let Err(e) = run_maybe_sudo(false, apt, &["update"]) {
728                            // Retry with sudo if available
729                            if has("sudo") {
730                                run_maybe_sudo(true, apt, &["update"])?;
731                            } else {
732                                return Err(e);
733                            }
734                        }
735                    }
736                }
737            }
738            PackageManager::Dnf => { /* dnf auto-refreshes; optional */ }
739            PackageManager::Yum => { /* optional */ }
740            PackageManager::Zypper => {
741                require("zypper", "zypper is required to update packages")?;
742                match use_sudo {
743                    Some(flag) => {
744                        if flag {
745                            require("sudo", "sudo is required to update packages")?;
746                        }
747                        run_maybe_sudo(flag, "zypper", &["--non-interactive", "refresh"])?;
748                    }
749                    None => {
750                        if let Err(e) =
751                            run_maybe_sudo(false, "zypper", &["--non-interactive", "refresh"])
752                        {
753                            if has("sudo") {
754                                run_maybe_sudo(true, "zypper", &["--non-interactive", "refresh"])?;
755                            } else {
756                                return Err(e);
757                            }
758                        }
759                    }
760                }
761            }
762            PackageManager::Pacman => {
763                require("pacman", "pacman is required to update packages")?;
764                match use_sudo {
765                    Some(flag) => {
766                        if flag {
767                            require("sudo", "sudo is required to update packages")?;
768                        }
769                        run_maybe_sudo(flag, "pacman", &["-Sy"])?;
770                    }
771                    None => {
772                        if let Err(e) = run_maybe_sudo(false, "pacman", &["-Sy"]) {
773                            if has("sudo") {
774                                run_maybe_sudo(true, "pacman", &["-Sy"])?;
775                            } else {
776                                return Err(e);
777                            }
778                        }
779                    }
780                }
781            }
782            PackageManager::Apk => { /* `apk add` refreshes on demand */ }
783            PackageManager::Pkg => {
784                require("pkg", "FreeBSD pkg is required to update packages")?;
785                match use_sudo {
786                    Some(flag) => {
787                        if flag {
788                            require("sudo", "sudo is required to update packages")?;
789                        }
790                        run_maybe_sudo(flag, "pkg", &["update"])?;
791                    }
792                    None => {
793                        if let Err(e) = run_maybe_sudo(false, "pkg", &["update"]) {
794                            if has("sudo") {
795                                run_maybe_sudo(true, "pkg", &["update"])?;
796                            } else {
797                                return Err(e);
798                            }
799                        }
800                    }
801                }
802            }
803            _ => {}
804        }
805        Ok(())
806    }
807
808    pub fn install(
809        pm: PackageManager,
810        pkg: &str,
811        use_sudo: Option<bool>,
812    ) -> Result<(), InstallError> {
813        match pm {
814            PackageManager::Apt => {
815                let apt = require_any(&["apt-get", "apt"], "apt is required to install packages")?;
816                match use_sudo {
817                    Some(flag) => {
818                        if flag {
819                            require("sudo", "sudo is required to install packages")?;
820                        }
821                        run_maybe_sudo(flag, apt, &["install", "-y", pkg])?;
822                    }
823                    None => {
824                        if let Err(e) = run_maybe_sudo(false, apt, &["install", "-y", pkg]) {
825                            if has("sudo") {
826                                run_maybe_sudo(true, apt, &["install", "-y", pkg])?;
827                            } else {
828                                return Err(e);
829                            }
830                        }
831                    }
832                }
833            }
834            PackageManager::Dnf => {
835                require("dnf", "dnf is required to install packages")?;
836                match use_sudo {
837                    Some(flag) => {
838                        if flag {
839                            require("sudo", "sudo is required to install packages")?;
840                        }
841                        run_maybe_sudo(flag, "dnf", &["install", "-y", pkg])?;
842                    }
843                    None => {
844                        if let Err(e) = run_maybe_sudo(false, "dnf", &["install", "-y", pkg]) {
845                            if has("sudo") {
846                                run_maybe_sudo(true, "dnf", &["install", "-y", pkg])?;
847                            } else {
848                                return Err(e);
849                            }
850                        }
851                    }
852                }
853            }
854            PackageManager::Yum => {
855                require("yum", "yum is required to install packages")?;
856                match use_sudo {
857                    Some(flag) => {
858                        if flag {
859                            require("sudo", "sudo is required to install packages")?;
860                        }
861                        run_maybe_sudo(flag, "yum", &["install", "-y", pkg])?;
862                    }
863                    None => {
864                        if let Err(e) = run_maybe_sudo(false, "yum", &["install", "-y", pkg]) {
865                            if has("sudo") {
866                                run_maybe_sudo(true, "yum", &["install", "-y", pkg])?;
867                            } else {
868                                return Err(e);
869                            }
870                        }
871                    }
872                }
873            }
874            PackageManager::Zypper => {
875                require("zypper", "zypper is required to install packages")?;
876                match use_sudo {
877                    Some(flag) => {
878                        if flag {
879                            require("sudo", "sudo is required to install packages")?;
880                        }
881                        run_maybe_sudo(
882                            flag,
883                            "zypper",
884                            &["--non-interactive", "install", "--no-confirm", pkg],
885                        )?;
886                    }
887                    None => {
888                        if let Err(e) = run_maybe_sudo(
889                            false,
890                            "zypper",
891                            &["--non-interactive", "install", "--no-confirm", pkg],
892                        ) {
893                            if has("sudo") {
894                                run_maybe_sudo(
895                                    true,
896                                    "zypper",
897                                    &["--non-interactive", "install", "--no-confirm", pkg],
898                                )?;
899                            } else {
900                                return Err(e);
901                            }
902                        }
903                    }
904                }
905            }
906            PackageManager::Pacman => {
907                require("pacman", "pacman is required to install packages")?;
908                match use_sudo {
909                    Some(flag) => {
910                        if flag {
911                            require("sudo", "sudo is required to install packages")?;
912                        }
913                        run_maybe_sudo(flag, "pacman", &["--noconfirm", "-S", pkg])?;
914                    }
915                    None => {
916                        if let Err(e) = run_maybe_sudo(false, "pacman", &["--noconfirm", "-S", pkg])
917                        {
918                            if has("sudo") {
919                                run_maybe_sudo(true, "pacman", &["--noconfirm", "-S", pkg])?;
920                            } else {
921                                return Err(e);
922                            }
923                        }
924                    }
925                }
926            }
927            PackageManager::Apk => {
928                require("apk", "apk is required to install packages")?;
929                match use_sudo {
930                    Some(flag) => {
931                        if flag {
932                            require("sudo", "sudo is required to install packages")?;
933                        }
934                        run_maybe_sudo(flag, "apk", &["add", pkg])?;
935                    }
936                    None => {
937                        if let Err(e) = run_maybe_sudo(false, "apk", &["add", pkg]) {
938                            if has("sudo") {
939                                run_maybe_sudo(true, "apk", &["add", pkg])?;
940                            } else {
941                                return Err(e);
942                            }
943                        }
944                    }
945                }
946            }
947            // These package managers generally do not require sudo by design here
948            PackageManager::Brew => {
949                require("brew", "Homebrew is required to install packages")?;
950                run("brew", &["install", pkg])?;
951            }
952            PackageManager::BrewCask => {
953                require("brew", "Homebrew is required to install casks")?;
954                run("brew", &["install", "--cask", pkg])?;
955            }
956            PackageManager::Winget => {
957                require("winget", "Winget is required to install packages")?;
958                run("winget", &["install", "-e", "--id", pkg])?;
959            }
960            PackageManager::Choco => {
961                require("choco", "Chocolatey is required to install packages")?;
962                run("choco", &["install", "-y", pkg])?;
963            }
964            PackageManager::Pkg => {
965                require("pkg", "FreeBSD pkg is required to install packages")?;
966                match use_sudo {
967                    Some(flag) => {
968                        if flag {
969                            require("sudo", "sudo is required to install packages")?;
970                        }
971                        run_maybe_sudo(flag, "pkg", &["install", "-y", pkg])?;
972                    }
973                    None => {
974                        if let Err(e) = run_maybe_sudo(false, "pkg", &["install", "-y", pkg]) {
975                            if has("sudo") {
976                                run_maybe_sudo(true, "pkg", &["install", "-y", pkg])?;
977                            } else {
978                                return Err(e);
979                            }
980                        }
981                    }
982                }
983            }
984        };
985        Ok(())
986    }
987}