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