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