Skip to main content

zoi_core/
utils.rs

1use std::collections::HashMap;
2use std::fs;
3use std::io::{Write, stdin, stdout};
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use std::sync::OnceLock;
7use std::time::Duration;
8
9use anyhow::{Result, anyhow};
10use clap_complete::Shell;
11use colored::Colorize;
12use crossterm::tty::IsTty;
13#[cfg(unix)]
14use nix;
15use sha2::{Digest, Sha512};
16
17/// Creates an HTTP client with Zoi's default configuration.
18///
19/// # Errors
20///
21/// Returns an error if Zoi is in offline mode or if the HTTP client cannot be
22/// built.
23pub fn get_http_client() -> Result<&'static reqwest::blocking::Client> {
24    static HTTP_CLIENT: OnceLock<reqwest::blocking::Client> = OnceLock::new();
25    if crate::offline::is_offline() {
26        return Err(anyhow!(
27            "Cannot create HTTP client: Zoi is in offline mode."
28        ));
29    }
30    if let Some(client) = HTTP_CLIENT.get() {
31        return Ok(client);
32    }
33    let client = reqwest::blocking::Client::builder()
34        .user_agent("zoi")
35        .timeout(Duration::from_mins(1))
36        .use_rustls_tls()
37        .build()
38        .map_err(|e| anyhow!("Failed to build HTTP client: {e}"))?;
39    let _ = HTTP_CLIENT.set(client);
40    HTTP_CLIENT
41        .get()
42        .ok_or_else(|| anyhow!("HTTP_CLIENT should be set but was missing"))
43}
44
45/// Builds a blocking HTTP client with a custom timeout.
46///
47/// # Errors
48///
49/// Returns an error if Zoi is in offline mode or if the HTTP client cannot be
50/// built.
51pub fn build_blocking_http_client(
52    timeout_secs: u64
53) -> Result<reqwest::blocking::Client> {
54    if crate::offline::is_offline() {
55        return Err(anyhow!(
56            "Cannot create HTTP client: Zoi is in offline mode."
57        ));
58    }
59    let client = reqwest::blocking::Client::builder()
60        .user_agent("zoi")
61        .timeout(Duration::from_secs(timeout_secs))
62        .use_rustls_tls()
63        .build()?;
64    Ok(client)
65}
66
67/// Creates a symbolic link for a directory, handling platform-specific
68/// requirements.
69///
70/// # Errors
71///
72/// Returns an error if the symlink or directory operations fail.
73pub fn symlink_dir(target: &Path, link: &Path) -> std::io::Result<()> {
74    if link.exists() || link.is_symlink() {
75        if link.is_dir() && !link.is_symlink() {
76            fs::remove_dir_all(link)?;
77        } else {
78            fs::remove_file(link)?;
79        }
80    }
81    #[cfg(unix)]
82    {
83        std::os::unix::fs::symlink(target, link)?;
84    }
85    #[cfg(windows)]
86    {
87        if std::os::windows::fs::symlink_dir(target, link).is_err() {
88            if junction::create(target, link).is_err() {
89                copy_dir_all(target, link)?;
90            }
91        }
92    }
93    Ok(())
94}
95
96/// Checks if a command exists in the system's PATH.
97pub fn command_exists(command: &str) -> bool {
98    if cfg!(target_os = "windows") {
99        Command::new("where")
100            .arg(command)
101            .stdout(std::process::Stdio::null())
102            .stderr(std::process::Stdio::null())
103            .status()
104            .is_ok_and(|status| status.success())
105    } else {
106        Command::new("bash")
107            .arg("-c")
108            // Pass the command as a positional argument so metadata supplied
109            // names cannot alter the shell program being executed.
110            .arg("command -v -- \"$1\"")
111            .arg("zoi-command-exists")
112            .arg(command)
113            .stdout(std::process::Stdio::null())
114            .stderr(std::process::Stdio::null())
115            .status()
116            .is_ok_and(|status| status.success())
117    }
118}
119
120/// Returns a standard Zoi platform identifier (e.g. "linux-amd64",
121/// "windows-arm64").
122///
123/// This string is used extensively in registries and package definitions to
124/// handle platform-specific dependencies and build artifacts.
125///
126/// # Errors
127///
128/// Returns an error if the current operating system or architecture is
129/// unsupported.
130pub fn get_platform() -> Result<String> {
131    let os = match std::env::consts::OS {
132        "linux" => "linux",
133        "macos" | "darwin" => "macos",
134        "windows" => "windows",
135        unsupported_os => {
136            return Err(anyhow!(
137                "Unsupported operating system: {unsupported_os}"
138            ));
139        }
140    };
141    let arch = match std::env::consts::ARCH {
142        "x86_64" | "amd64" => "amd64",
143        "aarch64" | "arm64" => "arm64",
144        "x86" | "i386" | "i686" => "386",
145        unsupported_arch => {
146            return Err(anyhow!(
147                "Unsupported architecture: {unsupported_arch}"
148            ));
149        }
150    };
151    Ok(format!("{os}-{arch}"))
152}
153
154/// Returns the home directory of the current user, or the original user if run
155/// via sudo or doas.
156pub fn get_user_home() -> Option<PathBuf> {
157    if let Ok(user_var) =
158        std::env::var("SUDO_USER").or_else(|_| std::env::var("DOAS_USER"))
159    {
160        #[cfg(unix)]
161        {
162            use nix::unistd::User;
163            if let Ok(Some(user)) = User::from_name(&user_var) {
164                return Some(user.dir);
165            }
166        }
167        #[cfg(not(unix))]
168        let _ = user_var;
169    }
170    home::home_dir()
171}
172
173/// Returns Zoi's user configuration directory according to the XDG Base
174/// Directory specification.
175///
176/// # Errors
177///
178/// Returns an error if no suitable user configuration directory can be found.
179pub fn get_user_config_dir() -> Result<PathBuf> {
180    let root =
181        xdg_or_platform_dir("XDG_CONFIG_HOME", dirs::config_dir, |home| {
182            home.join(".config")
183        })?;
184    Ok(crate::sysroot::apply_sysroot(root.join("zoi")))
185}
186
187/// Returns Zoi's user data directory according to the XDG Base Directory
188/// specification.
189///
190/// # Errors
191///
192/// Returns an error if no suitable user data directory can be found.
193pub fn get_user_data_dir() -> Result<PathBuf> {
194    let root =
195        xdg_or_platform_dir("XDG_DATA_HOME", dirs::data_local_dir, |home| {
196            home.join(".local/share")
197        })?;
198    Ok(crate::sysroot::apply_sysroot(root.join("zoi")))
199}
200
201/// Returns Zoi's user cache directory according to the XDG Base Directory
202/// specification.
203///
204/// # Errors
205///
206/// Returns an error if no suitable user cache directory can be found.
207pub fn get_user_cache_dir() -> Result<PathBuf> {
208    let root =
209        xdg_or_platform_dir("XDG_CACHE_HOME", dirs::cache_dir, |home| {
210            home.join(".cache")
211        })?;
212    Ok(crate::sysroot::apply_sysroot(root.join("zoi")))
213}
214
215/// Returns Zoi's user state directory, honoring `XDG_STATE_HOME` first.
216///
217/// # Errors
218///
219/// Returns an error if no suitable user state directory can be found.
220pub fn get_user_state_dir() -> Result<PathBuf> {
221    let root =
222        xdg_or_platform_dir("XDG_STATE_HOME", dirs::data_local_dir, |home| {
223            home.join(".local/state")
224        })?;
225    Ok(crate::sysroot::apply_sysroot(root.join("zoi")))
226}
227
228/// Resolves an XDG directory, falling back to the native platform convention.
229fn xdg_or_platform_dir(
230    variable: &str,
231    platform_dir: impl FnOnce() -> Option<PathBuf>,
232    unix_fallback: impl FnOnce(PathBuf) -> PathBuf
233) -> Result<PathBuf> {
234    if let Some(path) =
235        std::env::var_os(variable).filter(|path| !path.is_empty())
236    {
237        let path = PathBuf::from(path);
238        if path.is_absolute() {
239            return Ok(path);
240        }
241    }
242    if cfg!(unix) && !cfg!(target_os = "macos") {
243        return get_user_home()
244            .map(unix_fallback)
245            .ok_or_else(|| anyhow!("Could not find home directory."));
246    }
247    platform_dir()
248        .ok_or_else(|| anyhow!("Could not determine platform directory."))
249}
250
251/// Returns the directory used for user-installed command shims.
252///
253/// # Errors
254///
255/// Returns an error if the user data directory cannot be determined.
256pub fn get_user_bin_dir() -> Result<PathBuf> {
257    Ok(get_user_data_dir()?.join("pkgs").join("bin"))
258}
259
260/// Returns the directory used for user shell-completion files.
261///
262/// # Errors
263///
264/// Returns an error if the user data directory cannot be determined.
265pub fn get_user_completions_dir(shell: &str) -> Result<PathBuf> {
266    Ok(get_user_data_dir()?.join("pkgs").join("shell").join(shell))
267}
268
269/// Returns the platform-native directory for system-wide Zoi data.
270pub fn get_system_data_dir() -> PathBuf {
271    let path = if cfg!(target_os = "windows") {
272        PathBuf::from("C:\\ProgramData\\zoi")
273    } else if cfg!(target_os = "macos") {
274        PathBuf::from("/Library/Application Support/zoi")
275    } else {
276        PathBuf::from("/var/lib/zoi")
277    };
278    crate::sysroot::apply_sysroot(path)
279}
280
281/// Returns the platform-native directory for system-wide Zoi configuration.
282pub fn get_system_config_dir() -> PathBuf {
283    let path = if cfg!(target_os = "windows") {
284        PathBuf::from("C:\\ProgramData\\zoi")
285    } else if cfg!(target_os = "macos") {
286        PathBuf::from("/Library/Application Support/zoi")
287    } else {
288        PathBuf::from("/etc/zoi")
289    };
290    crate::sysroot::apply_sysroot(path)
291}
292
293/// Returns the platform-native directory for system-wide Zoi cache files.
294pub fn get_system_cache_dir() -> PathBuf {
295    let path = if cfg!(target_os = "windows") {
296        PathBuf::from("C:\\ProgramData\\zoi\\cache")
297    } else if cfg!(target_os = "macos") {
298        PathBuf::from("/Library/Caches/zoi")
299    } else {
300        PathBuf::from("/var/cache/zoi")
301    };
302    crate::sysroot::apply_sysroot(path)
303}
304
305/// Returns the root directory for the package database.
306///
307/// # Errors
308///
309/// Returns an error if the home directory cannot be determined.
310pub fn get_db_root() -> Result<std::path::PathBuf> {
311    if let Ok(path) = std::env::var("ZOI_DB_DIR") {
312        return Ok(std::path::PathBuf::from(path));
313    }
314    Ok(get_user_data_dir()?.join("pkgs").join("db"))
315}
316
317/// Returns the root directory of the package store for a given scope.
318///
319/// Store Locations:
320/// - `User`: `$XDG_DATA_HOME/zoi/pkgs/store/`, or the native fallback.
321/// - `System`: `/var/lib/zoi/pkgs/store/` (Linux) or
322///   `C:\ProgramData\zoi\pkgs\store` (Windows)
323/// - `Project`: `./.zoi/pkgs/store/` (Relative to current project root)
324///
325/// # Errors
326///
327/// Returns an error if the home directory or current directory cannot be
328/// determined.
329pub fn get_store_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
330    match scope {
331        crate::types::Scope::User => {
332            Ok(get_user_data_dir()?.join("pkgs").join("store"))
333        }
334        crate::types::Scope::System => {
335            Ok(get_system_data_dir().join("pkgs").join("store"))
336        }
337        crate::types::Scope::Project => {
338            let current_dir = std::env::current_dir()?;
339            Ok(current_dir.join(".zoi").join("pkgs").join("store"))
340        }
341    }
342}
343
344/// Returns the root directory of the package database for a given scope.
345///
346/// # Errors
347///
348/// Returns an error if the home directory or current directory cannot be
349/// determined.
350pub fn get_db_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
351    match scope {
352        crate::types::Scope::User => {
353            Ok(get_user_data_dir()?.join("pkgs").join("db"))
354        }
355        crate::types::Scope::System => {
356            Ok(get_system_data_dir().join("pkgs").join("db"))
357        }
358        crate::types::Scope::Project => {
359            let current_dir = std::env::current_dir()?;
360            Ok(current_dir.join(".zoi").join("pkgs").join("db"))
361        }
362    }
363}
364
365/// Returns the root directory for Git repositories for a given scope.
366///
367/// # Errors
368///
369/// Returns an error if the home directory or current directory cannot be
370/// determined.
371pub fn get_git_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
372    match scope {
373        crate::types::Scope::User => {
374            Ok(get_user_data_dir()?.join("pkgs").join("git"))
375        }
376        crate::types::Scope::System => {
377            Ok(get_system_data_dir().join("pkgs").join("git"))
378        }
379        crate::types::Scope::Project => {
380            let current_dir = std::env::current_dir()?;
381            Ok(current_dir.join(".zoi").join("pkgs").join("git"))
382        }
383    }
384}
385
386/// Generates a unique, origin-aware ID for a package.
387///
388/// This ID prevents collisions between packages with the same name that reside
389/// in different registries or repository tiers.
390///
391/// ID Format: `#{registry-handle}@{repo-path}/{package-name}`
392/// Hashed Result: First 32 characters of the SHA-512 hash of the ID string.
393pub fn generate_package_id(
394    registry_handle: &str,
395    repo_path: &str,
396    package_name: &str
397) -> String {
398    let format_string =
399        format!("#{registry_handle}@{repo_path}/{package_name}");
400    let mut hasher = Sha512::new();
401    hasher.update(format_string.as_bytes());
402    let result = hasher.finalize();
403    let hex_string = hex::encode(result);
404    hex_string[..32].to_string()
405}
406
407/// Generates a unique ID for a package including its version.
408pub fn generate_versioned_package_id(
409    registry_handle: &str,
410    repo_path: &str,
411    package_name: &str,
412    version: &str
413) -> String {
414    let format_string =
415        format!("#{registry_handle}@{repo_path}/{package_name}@{version}");
416    let mut hasher = Sha512::new();
417    hasher.update(format_string.as_bytes());
418    let result = hasher.finalize();
419    let hex_string = hex::encode(result);
420    hex_string[..32].to_string()
421}
422
423/// Creates the directory name for the package in the store.
424/// Format: `{hash}-{name}`
425pub fn get_package_dir_name(package_id: &str, package_name: &str) -> String {
426    format!("{package_id}-{package_name}")
427}
428
429/// Recursively copies all files and subdirectories from source to destination.
430///
431/// # Errors
432///
433/// Returns an error if the directory creation or file copy operation fails.
434pub fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
435    let src = if src.as_os_str().is_empty() {
436        Path::new(".")
437    } else {
438        src
439    };
440    fs::create_dir_all(dst)?;
441    for entry in fs::read_dir(src)? {
442        let entry = entry?;
443        let ty = entry.file_type()?;
444        if ty.is_dir() {
445            copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
446        } else if ty.is_symlink() {
447            let target = fs::read_link(entry.path())?;
448            let destination = dst.join(entry.file_name());
449            #[cfg(unix)]
450            std::os::unix::fs::symlink(target, destination)?;
451            #[cfg(windows)]
452            {
453                if target.is_dir() {
454                    std::os::windows::fs::symlink_dir(target, destination)?;
455                } else {
456                    std::os::windows::fs::symlink_file(target, destination)?;
457                }
458            }
459        } else {
460            fs::copy(entry.path(), dst.join(entry.file_name()))?;
461        }
462    }
463    Ok(())
464}
465
466/// Performs a jittered exponential backoff sleep.
467///
468/// Used during network retries to prevent thundering herd problems and
469/// improve reliability on unstable connections.
470pub fn retry_backoff_sleep(attempt: u32) {
471    let base_ms = 500u64.saturating_mul(1u64 << (attempt.saturating_sub(1)));
472    let jitter = u64::from(
473        std::time::SystemTime::now()
474            .duration_since(std::time::UNIX_EPOCH)
475            .unwrap_or(Duration::from_secs(0))
476            .subsec_millis()
477            % 200
478    );
479    let sleep_ms = (base_ms + jitter).min(8000);
480    std::thread::sleep(Duration::from_millis(sleep_ms));
481}
482
483/// Retrieves information about the current Linux distribution from
484/// /etc/os-release.
485pub fn get_linux_distribution_info() -> Option<HashMap<String, String>> {
486    let path = crate::sysroot::apply_sysroot("/etc/os-release");
487    if let Ok(contents) = fs::read_to_string(path) {
488        let info: HashMap<String, String> = contents
489            .lines()
490            .filter_map(|line| {
491                let mut parts = line.splitn(2, '=');
492                let key = parts.next()?;
493                let value = parts.next()?.trim_matches('"').to_string();
494                if key.is_empty() {
495                    None
496                } else {
497                    Some((key.to_string(), value))
498                }
499            })
500            .collect();
501        if info.is_empty() { None } else { Some(info) }
502    } else {
503        None
504    }
505}
506
507/// Detects the general family of a Linux distribution (e.g. "debian", "arch",
508/// "fedora").
509///
510/// This is used to map specific distributions to their primary package manager
511/// and standard filesystem locations.
512///
513/// Strategy:
514/// - `ID_LIKE` Check: We first check the `ID_LIKE` field in `/etc/os-release`.
515///   This is the most reliable way to identify derivatives (e.g. Ubuntu is
516///   `debian`).
517/// - Direct ID Match: If `ID_LIKE` is missing, we fall back to the primary
518///   `ID`.
519/// - Normalization: We group similar distros under a common "family" key to
520///   simplify downstream logic (e.g. Rocky, Alma, and `CentOS` all map to
521///   `fedora` because they share the DNF/RPM ecosystem).
522pub fn get_linux_distro_family() -> Option<String> {
523    if is_zoios() {
524        return Some("zoios".to_string());
525    }
526    if let Some(info) = get_linux_distribution_info() {
527        if let Some(id_like) = info.get("ID_LIKE") {
528            let families: Vec<&str> = id_like.split_whitespace().collect();
529            if families.contains(&"debian") {
530                return Some("debian".to_string());
531            }
532            if families.contains(&"arch") {
533                return Some("arch".to_string());
534            }
535            if families.contains(&"fedora") {
536                return Some("fedora".to_string());
537            }
538            if families.contains(&"rhel") {
539                return Some("fedora".to_string());
540            }
541            if families.contains(&"suse") {
542                return Some("suse".to_string());
543            }
544            if families.contains(&"gentoo") {
545                return Some("gentoo".to_string());
546            }
547        }
548        if let Some(id) = info.get("ID") {
549            return match id.as_str() {
550                "debian" | "ubuntu" | "linuxmint" | "pop" | "kali"
551                | "kubuntu" | "lubuntu" | "xubuntu" | "zorin"
552                | "elementary" => Some("debian".to_string()),
553                "arch" | "manjaro" | "cachyos" | "endeavouros" | "garuda" => {
554                    Some("arch".to_string())
555                }
556                "fedora" | "centos" | "rhel" | "rocky" | "almalinux" => {
557                    Some("fedora".to_string())
558                }
559                "opensuse" | "opensuse-tumbleweed" | "opensuse-leap" => {
560                    Some("suse".to_string())
561                }
562                "gentoo" => Some("gentoo".to_string()),
563                "alpine" => Some("alpine".to_string()),
564                "void" => Some("void".to_string()),
565                "solus" => Some("solus".to_string()),
566                "guix" => Some("guix".to_string()),
567                _ => None
568            };
569        }
570    }
571    None
572}
573
574/// Returns the ID of the current Linux distribution (e.g. "debian", "fedora").
575pub fn get_linux_distribution() -> Option<String> {
576    get_linux_distribution_info().and_then(|info| info.get("ID").cloned())
577}
578
579/// Returns true if the current system is a ZoiOS-based distribution (like
580/// Parlex).
581pub fn is_zoios() -> bool {
582    if let Some(info) = get_linux_distribution_info() {
583        if let Some(id) = info.get("ID")
584            && (id == "zoios" || id == "parlex")
585        {
586            return true;
587        }
588        if let Some(id_like) = info.get("ID_LIKE")
589            && id_like.split_whitespace().any(|s| s == "zoios")
590        {
591            return true;
592        }
593    }
594    false
595}
596
597/// Resolves the default installation scope based on the current environment.
598pub fn resolve_fallback_scope() -> crate::types::Scope {
599    if std::path::Path::new("zoi.lua").exists()
600        || std::path::Path::new("zoi.yaml").exists()
601    {
602        crate::types::Scope::Project
603    } else if is_zoios() {
604        crate::types::Scope::System
605    } else {
606        crate::types::Scope::User
607    }
608}
609
610/// Returns the name of the current desktop environment (e.g. "gnome", "kde").
611pub fn get_desktop_environment() -> Option<String> {
612    if cfg!(target_os = "windows") {
613        return Some("windows".to_string());
614    }
615    if let Ok(de) = std::env::var("XDG_CURRENT_DESKTOP")
616        && !de.is_empty()
617    {
618        return Some(de.to_lowercase());
619    }
620    if let Ok(ds) = std::env::var("DESKTOP_SESSION")
621        && !ds.is_empty()
622    {
623        return Some(ds.to_lowercase());
624    }
625    None
626}
627
628/// Returns the name of the current display server (e.g. "wayland", "x11").
629pub fn get_display_server() -> Option<String> {
630    if cfg!(target_os = "windows") {
631        return Some("windows".to_string());
632    }
633    if cfg!(target_os = "macos") {
634        return Some("quartz".to_string());
635    }
636    if let Ok(st) = std::env::var("XDG_SESSION_TYPE")
637        && !st.is_empty()
638    {
639        return Some(st.to_lowercase());
640    }
641    None
642}
643
644/// Returns the version of the operating system kernel.
645pub fn get_kernel_version() -> Option<String> {
646    if cfg!(unix) {
647        let output = Command::new("uname").arg("-r").output().ok()?;
648        if output.status.success() {
649            return Some(
650                String::from_utf8_lossy(&output.stdout).trim().to_string()
651            );
652        }
653    } else if cfg!(target_os = "windows") {
654        let output = Command::new("pwsh")
655            .arg("-Command")
656            .arg("(Get-CimInstance Win32_OperatingSystem).Version")
657            .output()
658            .ok()?;
659        if output.status.success() {
660            return Some(
661                String::from_utf8_lossy(&output.stdout).trim().to_string()
662            );
663        }
664    }
665    None
666}
667
668/// Returns the name of the system's init system (e.g. "systemd", "openrc").
669pub fn get_init_system() -> Option<String> {
670    if let Ok(val) = std::env::var("ZOI_INIT") {
671        return Some(val.to_lowercase());
672    }
673
674    let run_systemd =
675        crate::sysroot::apply_sysroot(PathBuf::from("/run/systemd/system"));
676    if run_systemd.exists() {
677        return Some("systemd".to_string());
678    }
679
680    let run_openrc =
681        crate::sysroot::apply_sysroot(PathBuf::from("/run/openrc"));
682    if run_openrc.exists() {
683        return Some("openrc".to_string());
684    }
685
686    let sbin_init = crate::sysroot::apply_sysroot(PathBuf::from("/sbin/init"));
687    if let Ok(target) = std::fs::read_link(&sbin_init) {
688        let target_str = target.to_string_lossy();
689        if target_str.contains("systemd") {
690            return Some("systemd".to_string());
691        } else if target_str.contains("openrc") {
692            return Some("openrc".to_string());
693        } else if target_str.contains("busybox") {
694            return Some("busybox".to_string());
695        }
696    }
697
698    None
699}
700
701/// Returns the name of the available privilege escalation tool (e.g. "sudo",
702/// "doas").
703pub fn get_privilege_escalator() -> Option<String> {
704    if command_exists("sudo") {
705        Some("sudo".to_string())
706    } else if command_exists("doas") {
707        Some("doas".to_string())
708    } else {
709        None
710    }
711}
712
713/// Returns the version of the current distribution.
714pub fn get_distro_version() -> Option<String> {
715    if let Some(info) = get_linux_distribution_info()
716        && let Some(vid) = info.get("VERSION_ID")
717    {
718        return Some(vid.clone());
719    }
720    if cfg!(target_os = "macos") {
721        let output = Command::new("sw_vers")
722            .arg("-productVersion")
723            .output()
724            .ok()?;
725        if output.status.success() {
726            return Some(
727                String::from_utf8_lossy(&output.stdout).trim().to_string()
728            );
729        }
730    } else if cfg!(target_os = "windows") {
731        let output = Command::new("pwsh")
732            .arg("-Command")
733            .arg("(Get-CimInstance Win32_OperatingSystem).Version")
734            .output()
735            .ok()?;
736        if output.status.success() {
737            return Some(
738                String::from_utf8_lossy(&output.stdout).trim().to_string()
739            );
740        }
741    }
742    None
743}
744
745/// Returns a short description of the system's CPU.
746pub fn get_cpu_info() -> Option<String> {
747    if cfg!(target_os = "linux") {
748        if let Ok(cpuinfo) = fs::read_to_string("/proc/cpuinfo") {
749            for line in cpuinfo.lines() {
750                if line.starts_with("model name")
751                    && let Some((_, model)) = line.split_once(':')
752                {
753                    return Some(model.trim().to_string());
754                }
755            }
756        }
757    } else if cfg!(target_os = "macos") {
758        let output = Command::new("sysctl")
759            .arg("-n")
760            .arg("machdep.cpu.brand_string")
761            .output()
762            .ok()?;
763        if output.status.success() {
764            return Some(
765                String::from_utf8_lossy(&output.stdout).trim().to_string()
766            );
767        }
768    } else if cfg!(target_os = "windows") {
769        let output = Command::new("pwsh")
770            .arg("-Command")
771            .arg("(Get-CimInstance Win32_Processor).Name")
772            .output()
773            .ok()?;
774        if output.status.success() {
775            return Some(
776                String::from_utf8_lossy(&output.stdout).trim().to_string()
777            );
778        }
779    }
780    None
781}
782
783/// Returns a short description of the system's GPU.
784pub fn get_gpu_info() -> Option<String> {
785    if cfg!(target_os = "linux") {
786        if let Ok(output) = Command::new("lspci").output()
787            && output.status.success()
788        {
789            let stdout = String::from_utf8_lossy(&output.stdout);
790            for line in stdout.lines() {
791                if (line.contains("VGA compatible controller")
792                    || line.contains("3D controller"))
793                    && let Some((_, model)) = line.split_once(": ")
794                {
795                    return Some(model.trim().to_string());
796                }
797            }
798        }
799    } else if cfg!(target_os = "macos") {
800        let output = Command::new("system_profiler")
801            .arg("SPDisplaysDataType")
802            .output()
803            .ok()?;
804        if output.status.success() {
805            let stdout = String::from_utf8_lossy(&output.stdout);
806            for line in stdout.lines() {
807                if line.trim().starts_with("Chipset Model:")
808                    && let Some((_, model)) = line.split_once(':')
809                {
810                    return Some(model.trim().to_string());
811                }
812            }
813        }
814    } else if cfg!(target_os = "windows") {
815        let output = Command::new("pwsh")
816            .arg("-Command")
817            .arg("(Get-CimInstance Win32_VideoController).Name")
818            .output()
819            .ok()?;
820        if output.status.success() {
821            return Some(
822                String::from_utf8_lossy(&output.stdout).trim().to_string()
823            );
824        }
825    }
826    None
827}
828
829/// Identifies the primary package manager for the current operating system.
830///
831/// This is used to resolve `native:` dependencies.
832pub fn get_native_package_manager() -> Option<String> {
833    let os = std::env::consts::OS;
834    match os {
835        "linux" => get_linux_distro_family()
836            .map(|family| {
837                match family.as_str() {
838                    "debian" => "apt",
839                    "arch" => "pacman",
840                    "fedora" => "dnf",
841                    "suse" => "zypper",
842                    "gentoo" => "portage",
843                    "alpine" => "apk",
844                    "void" => "xbps-install",
845                    "solus" => "eopkg",
846                    "guix" => "guix",
847                    "zoios" => "zoi",
848                    _ => "unknown"
849                }
850                .to_string()
851            })
852            .filter(|s| s != "unknown"),
853        "macos" => {
854            if command_exists("brew") {
855                Some("brew".to_string())
856            } else if command_exists("port") {
857                Some("macports".to_string())
858            } else {
859                None
860            }
861        }
862        "windows" => {
863            if command_exists("scoop") {
864                Some("scoop".to_string())
865            } else if command_exists("choco") {
866                Some("choco".to_string())
867            } else if command_exists("winget") {
868                Some("winget".to_string())
869            } else {
870                None
871            }
872        }
873        _ => None
874    }
875}
876
877/// Scans the system for all supported package managers.
878///
879/// This provides the list of available managers shown in `zoi info` and
880/// used to validate `manager:` prefixes in dependency strings.
881pub fn get_all_available_package_managers() -> Vec<String> {
882    let mut managers = Vec::new();
883    let all_possible_managers = [
884        "apt",
885        "pacman",
886        "yay",
887        "paru",
888        "pikaur",
889        "trizen",
890        "dnf",
891        "yum",
892        "zypper",
893        "portage",
894        "apk",
895        "snap",
896        "flatpak",
897        "nix",
898        "brew",
899        "port",
900        "scoop",
901        "choco",
902        "winget",
903        "pkg",
904        "pkg_add",
905        "xbps-install",
906        "eopkg",
907        "guix",
908        "mas"
909    ];
910
911    for manager in &all_possible_managers {
912        if command_exists(manager) {
913            managers.push(manager.to_string());
914        }
915    }
916    managers.sort();
917    managers.dedup();
918    managers
919}
920
921/// Formats a byte count into a human-readable string (e.g. "1.24 MiB").
922pub fn format_bytes(bytes: u64) -> String {
923    const KIB: u64 = 1024;
924    const MIB: u64 = 1024 * KIB;
925    const GIB: u64 = 1024 * MIB;
926    if bytes >= GIB {
927        format!("{:.2} GiB", bytes as f64 / GIB as f64)
928    } else if bytes >= MIB {
929        format!("{:.2} MiB", bytes as f64 / MIB as f64)
930    } else if bytes >= KIB {
931        format!("{:.2} KiB", bytes as f64 / KIB as f64)
932    } else {
933        format!("{bytes} B")
934    }
935}
936
937/// Formats a size difference into a signed human-readable string (e.g. "+50
938/// B").
939pub fn format_size_diff(diff: i64) -> String {
940    if diff == 0 {
941        return "0 B".to_string();
942    }
943    let sign = if diff > 0 { "+" } else { "-" };
944    let bytes = diff.unsigned_abs();
945    format!("{}{}", sign, format_bytes(bytes))
946}
947
948/// Verifies that a given path is "Safe" and doesn't attempt to escape the base
949/// directory.
950///
951/// This is a critical security check against "Path Traversal" attacks in
952/// package archives or Lua scripts.
953pub fn is_safe_path(base: &Path, path: &Path) -> bool {
954    let base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
955    let joined = if path.is_absolute() {
956        path.to_path_buf()
957    } else {
958        base.join(path)
959    };
960    let mut normalized = PathBuf::new();
961    for component in joined.components() {
962        match component {
963            std::path::Component::Prefix(_) | std::path::Component::RootDir => {
964                normalized.push(component);
965            }
966            std::path::Component::CurDir => {}
967            std::path::Component::ParentDir => {
968                if !normalized.pop() {
969                    return false;
970                }
971            }
972            std::path::Component::Normal(p) => normalized.push(p)
973        }
974    }
975    normalized.starts_with(&base)
976}
977
978/// Creates a symbolic link for a file, handling platform-specific requirements.
979///
980/// # Errors
981///
982/// Returns an error if the symlink operation fails.
983pub fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
984    if link.exists() || link.is_symlink() {
985        fs::remove_file(link)?;
986    }
987    #[cfg(unix)]
988    {
989        std::os::unix::fs::symlink(target, link)
990    }
991    #[cfg(windows)]
992    {
993        if std::os::windows::fs::symlink_file(target, link).is_err() {
994            if fs::hard_link(target, link).is_err() {
995                fs::copy(target, link)?;
996            }
997        }
998        Ok(())
999    }
1000}
1001
1002/// Checks if the current process has administrative privileges.
1003pub fn is_admin() -> bool {
1004    #[cfg(unix)]
1005    {
1006        nix::unistd::getuid().is_root()
1007    }
1008    #[cfg(windows)]
1009    {
1010        false
1011    }
1012}
1013
1014/// Executes a shell command and returns an error if it fails.
1015///
1016/// # Errors
1017///
1018/// Returns an error if the command fails to execute or returns a non-zero exit
1019/// status.
1020pub fn run_shell_command(command_str: &str) -> anyhow::Result<()> {
1021    let status = if cfg!(target_os = "windows") {
1022        Command::new("pwsh")
1023            .arg("-Command")
1024            .arg(command_str)
1025            .status()?
1026    } else {
1027        Command::new("bash").arg("-c").arg(command_str).status()?
1028    };
1029    if !status.success() {
1030        return Err(anyhow!("Command failed: {command_str}"));
1031    }
1032    Ok(())
1033}
1034
1035/// Executes a shell command quietly (suppressing output) and returns an error
1036/// if it fails.
1037///
1038/// # Errors
1039///
1040/// Returns an error if the command fails to execute or returns a non-zero exit
1041/// status.
1042pub fn run_shell_command_quietly(command_str: &str) -> anyhow::Result<()> {
1043    let status = if cfg!(target_os = "windows") {
1044        Command::new("pwsh")
1045            .arg("-Command")
1046            .arg(command_str)
1047            .stdout(std::process::Stdio::null())
1048            .stderr(std::process::Stdio::null())
1049            .status()?
1050    } else {
1051        Command::new("bash")
1052            .arg("-c")
1053            .arg(command_str)
1054            .stdout(std::process::Stdio::null())
1055            .stderr(std::process::Stdio::null())
1056            .status()?
1057    };
1058    if !status.success() {
1059        return Err(anyhow!("Command failed: {command_str}"));
1060    }
1061    Ok(())
1062}
1063
1064/// Returns true if Zoi is running in "Mini" mode (lightweight, zero-sync).
1065pub fn is_mini_mode() -> bool {
1066    std::env::var("ZOI_MINI_MODE").is_ok_and(|v| v == "1")
1067}
1068
1069/// Prompts the user for confirmation (y/N) unless the `yes` flag is set.
1070pub fn ask_for_confirmation(prompt: &str, yes: bool) -> bool {
1071    if yes {
1072        return true;
1073    }
1074    if std::env::var("ZOI_TEST").is_ok() || !stdin().is_tty() {
1075        return false;
1076    }
1077    print!("{prompt} [y/N]: ");
1078    let _ = stdout().flush();
1079    let mut input = String::new();
1080    if stdin().read_line(&mut input).is_err() {
1081        return false;
1082    }
1083    input.trim().eq_ignore_ascii_case("y")
1084}
1085
1086/// Recursively sets a directory and its contents to be read-only.
1087///
1088/// # Errors
1089///
1090/// Returns an error if the permission change fails.
1091pub fn set_path_read_only(path: &Path) -> anyhow::Result<()> {
1092    if !path.exists() {
1093        return Ok(());
1094    }
1095    for entry in walkdir::WalkDir::new(path) {
1096        let entry = entry?;
1097        let mut perms = fs::metadata(entry.path())?.permissions();
1098        if !perms.readonly() {
1099            perms.set_readonly(true);
1100            fs::set_permissions(entry.path(), perms)?;
1101        }
1102    }
1103    Ok(())
1104}
1105
1106/// Recursively ensures a directory and its contents are writable.
1107///
1108/// # Errors
1109///
1110/// Returns an error if the permission change fails.
1111pub fn set_path_writable(path: &Path) -> anyhow::Result<()> {
1112    if !path.exists() {
1113        return Ok(());
1114    }
1115    for entry in walkdir::WalkDir::new(path) {
1116        let entry = entry?;
1117        let mut perms = fs::metadata(entry.path())?.permissions();
1118        if perms.readonly() {
1119            #[cfg(unix)]
1120            {
1121                use std::os::unix::fs::PermissionsExt;
1122                let mode = perms.mode();
1123                perms.set_mode(mode | 0o200);
1124            }
1125            #[cfg(not(unix))]
1126            {
1127                perms.set_readonly(false);
1128            }
1129            fs::set_permissions(entry.path(), perms)?;
1130        }
1131    }
1132    Ok(())
1133}
1134
1135/// Sets the owner and group for a file or directory (Unix only).
1136///
1137/// # Errors
1138///
1139/// Returns an error if the owner or group lookup fails, or if the chown
1140/// operation fails.
1141#[cfg(unix)]
1142pub fn set_path_owner(
1143    path: &Path,
1144    owner: &str,
1145    group: &str
1146) -> anyhow::Result<()> {
1147    use nix::unistd::{Gid, Group, Uid, User, chown};
1148    let uid = if let Ok(u) = owner.parse::<u32>() {
1149        Some(Uid::from_raw(u))
1150    } else if !owner.is_empty() {
1151        Some(
1152            User::from_name(owner)
1153                .map_err(|e| anyhow!("Error looking up user '{owner}': {e}"))?
1154                .ok_or_else(|| anyhow!("User not found: {owner}"))?
1155                .uid
1156        )
1157    } else {
1158        None
1159    };
1160    let gid = if let Ok(g) = group.parse::<u32>() {
1161        Some(Gid::from_raw(g))
1162    } else if !group.is_empty() {
1163        Some(
1164            Group::from_name(group)
1165                .map_err(|e| anyhow!("Error looking up group '{group}': {e}"))?
1166                .ok_or_else(|| anyhow!("Group not found: {group}"))?
1167                .gid
1168        )
1169    } else {
1170        None
1171    };
1172    chown(path, uid, gid)
1173        .map_err(|e| anyhow!("Failed to chown '{}': {}", path.display(), e))?;
1174    Ok(())
1175}
1176
1177/// Checks if a target platform is compatible with a list of allowed platforms.
1178pub fn is_platform_compatible(
1179    current_platform: &str,
1180    allowed_platforms: &[String]
1181) -> bool {
1182    let os_part = current_platform
1183        .split('-')
1184        .next()
1185        .unwrap_or(current_platform);
1186    let os = match os_part {
1187        "darwin" => "macos",
1188        other => other
1189    };
1190    allowed_platforms.iter().any(|p| {
1191        if let Some(rest) = p.strip_prefix("ci:") {
1192            let target = rest.split(':').next().unwrap_or_default();
1193            target == current_platform || target == os
1194        } else {
1195            let p_norm = if p == "darwin" { "macos" } else { p };
1196            p_norm == "all" || p_norm == os || p_norm == current_platform
1197        }
1198    })
1199}
1200
1201/// Validates a package license against SPDX and organizational policies.
1202pub fn check_license(license: &str) {
1203    if license.is_empty() || license.eq_ignore_ascii_case("None") {
1204        return;
1205    }
1206    if license.eq_ignore_ascii_case("Proprietary")
1207        || license.eq_ignore_ascii_case("Unknown")
1208    {
1209        return;
1210    }
1211    match spdx::Expression::parse(license) {
1212        Ok(expr) => {
1213            if !expr.evaluate(|req| match req.license {
1214                spdx::LicenseItem::Spdx { id, .. } => id.is_osi_approved(),
1215                spdx::LicenseItem::Other { .. } => false
1216            }) {
1217                println!(
1218                    "{} License '{}' is not an OSI approved license.",
1219                    "Warning:".yellow(),
1220                    license.yellow().bold()
1221                );
1222            }
1223        }
1224        Err(_) => {
1225            println!(
1226                "{} Could not parse license expression '{}'. It may not be a \
1227                 valid SPDX identifier.",
1228                "Warning:".yellow(),
1229                license.yellow().bold()
1230            );
1231        }
1232    }
1233}
1234
1235/// Prompts the user to confirm installation from an untrusted (non-official)
1236/// source.
1237///
1238/// # Errors
1239///
1240/// Returns an error if the user aborts the operation.
1241pub fn confirm_untrusted_source(
1242    source_type: &crate::types::SourceType,
1243    yes: bool
1244) -> anyhow::Result<()> {
1245    if is_mini_mode() {
1246        return Ok(());
1247    }
1248    if source_type == &crate::types::SourceType::OfficialRepo {
1249        return Ok(());
1250    }
1251    let warning_message = match source_type {
1252        crate::types::SourceType::UntrustedRepo(repo) => {
1253            format!(
1254                "The package from repository '@{repo}' is not an official Zoi \
1255                 repository."
1256            )
1257        }
1258        crate::types::SourceType::LocalFile => {
1259            "You are installing from a local file.".to_string()
1260        }
1261        crate::types::SourceType::Url => {
1262            "You are installing from a remote URL. This script will be \
1263             executed with your user's permissions, which could lead to remote \
1264             code execution if the source is malicious."
1265                .to_string()
1266        }
1267        crate::types::SourceType::GitRepo(repo) => format!(
1268            "You are installing from an external git repository '{repo}'. \
1269             This script will be executed with your user's permissions."
1270        ),
1271        crate::types::SourceType::OfficialRepo => return Ok(())
1272    };
1273    println!(
1274        "\n{}: {}",
1275        "SECURITY WARNING".yellow().bold(),
1276        warning_message
1277    );
1278    if ask_for_confirmation(
1279        "This source is not trusted. Are you sure you want to continue?",
1280        yes
1281    ) {
1282        Ok(())
1283    } else {
1284        Err(anyhow!("Operation aborted by user."))
1285    }
1286}
1287
1288/// Expands standard Zoi path placeholders (e.g. `${pkgstore}`, `${usrhome}`)
1289/// in a string.
1290///
1291/// Note that `${createpkgdir}` is deliberately not expanded here: it depends
1292/// on the directory Zoi was invoked from during installation, so resolving it
1293/// at cleanup time could target an unrelated path. Pooled installations
1294/// record the absolute location for these entries instead.
1295///
1296/// # Errors
1297///
1298/// Returns an error if the current directory cannot be determined.
1299pub fn expand_placeholders(
1300    path: &str,
1301    version_dir: &Path,
1302    scope: crate::types::Scope
1303) -> Result<String> {
1304    let mut expanded = path.to_string();
1305    expanded = expanded.replace("${pkgstore}", &version_dir.to_string_lossy());
1306    expanded = expanded.replace(
1307        "${usrroot}",
1308        &crate::sysroot::apply_sysroot(PathBuf::from("/")).to_string_lossy()
1309    );
1310    if let Some(home_dir) = get_user_home() {
1311        expanded = expanded.replace("${usrhome}", &home_dir.to_string_lossy());
1312    }
1313
1314    let applications_dir = match scope {
1315        crate::types::Scope::System => PathBuf::from("/Applications"),
1316        crate::types::Scope::User => get_user_home().map_or_else(
1317            || PathBuf::from("/Applications"),
1318            |h| h.join("Applications")
1319        ),
1320        crate::types::Scope::Project => std::env::current_dir()
1321            .unwrap_or_default()
1322            .join("Applications")
1323    };
1324    expanded = expanded
1325        .replace("${applications}", &applications_dir.to_string_lossy());
1326
1327    Ok(expanded)
1328}
1329
1330/// Expands the `~` character to the user's home directory.
1331pub fn expand_tilde<P: AsRef<Path>>(path: P) -> PathBuf {
1332    let path = path.as_ref();
1333    if !path.starts_with("~") {
1334        return path.to_path_buf();
1335    }
1336    if let Some(home_dir) = get_user_home() {
1337        if path == Path::new("~") {
1338            return home_dir;
1339        }
1340        if let Ok(stripped) = path.strip_prefix("~/") {
1341            return home_dir.join(stripped);
1342        }
1343    }
1344    path.to_path_buf()
1345}
1346
1347/// Detects and returns the current shell being used.
1348pub fn get_current_shell() -> Option<Shell> {
1349    if cfg!(windows) {
1350        return Some(Shell::PowerShell);
1351    }
1352    if let Ok(shell_path) = std::env::var("SHELL") {
1353        let shell_name = Path::new(&shell_path).file_name()?.to_str()?;
1354        match shell_name {
1355            "bash" => Some(Shell::Bash),
1356            "zsh" => Some(Shell::Zsh),
1357            "fish" => Some(Shell::Fish),
1358            "elvish" => Some(Shell::Elvish),
1359            "pwsh" => Some(Shell::PowerShell),
1360            _ => None
1361        }
1362    } else {
1363        None
1364    }
1365}