Skip to main content

zoi_core/
utils.rs

1use anyhow::{Result, anyhow};
2use clap_complete::Shell;
3use colored::Colorize;
4use crossterm::tty::IsTty;
5use sha2::{Digest, Sha512};
6use std::collections::HashMap;
7use std::fs;
8use std::io::{Write, stdin, stdout};
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use std::sync::OnceLock;
12use std::time::Duration;
13
14#[cfg(unix)]
15use nix;
16
17/// Creates an HTTP client with Zoi's default configuration.
18pub fn get_http_client() -> Result<&'static reqwest::blocking::Client> {
19    if crate::offline::is_offline() {
20        return Err(anyhow!(
21            "Cannot create HTTP client: Zoi is in offline mode."
22        ));
23    }
24    static HTTP_CLIENT: OnceLock<reqwest::blocking::Client> = OnceLock::new();
25    if let Some(client) = HTTP_CLIENT.get() {
26        return Ok(client);
27    }
28    let client = reqwest::blocking::Client::builder()
29        .user_agent("zoi")
30        .timeout(Duration::from_secs(60))
31        .use_rustls_tls()
32        .build()
33        .map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?;
34    let _ = HTTP_CLIENT.set(client);
35    HTTP_CLIENT
36        .get()
37        .ok_or_else(|| anyhow!("HTTP_CLIENT should be set but was missing"))
38}
39
40pub fn build_blocking_http_client(timeout_secs: u64) -> Result<reqwest::blocking::Client> {
41    if crate::offline::is_offline() {
42        return Err(anyhow!(
43            "Cannot create HTTP client: Zoi is in offline mode."
44        ));
45    }
46    let client = reqwest::blocking::Client::builder()
47        .user_agent("zoi")
48        .timeout(Duration::from_secs(timeout_secs))
49        .use_rustls_tls()
50        .build()?;
51    Ok(client)
52}
53
54pub fn symlink_dir(target: &Path, link: &Path) -> std::io::Result<()> {
55    if link.exists() || link.is_symlink() {
56        if link.is_dir() && !link.is_symlink() {
57            fs::remove_dir_all(link)?;
58        } else {
59            fs::remove_file(link)?;
60        }
61    }
62    #[cfg(unix)]
63    {
64        std::os::unix::fs::symlink(target, link)?;
65    }
66    #[cfg(windows)]
67    {
68        if std::os::windows::fs::symlink_dir(target, link).is_err() {
69            if junction::create(target, link).is_err() {
70                copy_dir_all(target, link)?;
71            }
72        }
73    }
74    Ok(())
75}
76
77pub fn command_exists(command: &str) -> bool {
78    if cfg!(target_os = "windows") {
79        Command::new("where")
80            .arg(command)
81            .stdout(std::process::Stdio::null())
82            .stderr(std::process::Stdio::null())
83            .status()
84            .is_ok_and(|status| status.success())
85    } else {
86        Command::new("bash")
87            .arg("-c")
88            .arg(format!("command -v {}", command))
89            .stdout(std::process::Stdio::null())
90            .stderr(std::process::Stdio::null())
91            .status()
92            .is_ok_and(|status| status.success())
93    }
94}
95
96/// Returns a standard Zoi platform identifier (e.g. "linux-amd64", "windows-arm64").
97///
98/// This string is used extensively in registries and package definitions to
99/// handle platform-specific dependencies and build artifacts.
100pub fn get_platform() -> Result<String> {
101    let os = match std::env::consts::OS {
102        "linux" => "linux",
103        "macos" | "darwin" => "macos",
104        "windows" => "windows",
105        unsupported_os => return Err(anyhow!("Unsupported operating system: {}", unsupported_os)),
106    };
107    let arch = match std::env::consts::ARCH {
108        "x86_64" | "amd64" => "amd64",
109        "aarch64" | "arm64" => "arm64",
110        "x86" | "i386" | "i686" => "386",
111        unsupported_arch => return Err(anyhow!("Unsupported architecture: {}", unsupported_arch)),
112    };
113    Ok(format!("{}-{}", os, arch))
114}
115
116/// Returns the home directory of the current user, or the original user if run via sudo.
117pub fn get_user_home() -> Option<PathBuf> {
118    if let Ok(sudo_user) = std::env::var("SUDO_USER") {
119        #[cfg(unix)]
120        {
121            use nix::unistd::User;
122            if let Ok(Some(user)) = User::from_name(&sudo_user) {
123                return Some(user.dir);
124            }
125        }
126        #[cfg(not(unix))]
127        let _ = sudo_user;
128    }
129    home::home_dir()
130}
131
132/// Returns the root directory for the package database.
133pub fn get_db_root() -> Result<std::path::PathBuf> {
134    if let Ok(path) = std::env::var("ZOI_DB_DIR") {
135        return Ok(std::path::PathBuf::from(path));
136    }
137    let home_dir = get_user_home().ok_or_else(|| anyhow!("Could not find home directory."))?;
138    Ok(home_dir.join(".zoi").join("pkgs").join("db"))
139}
140
141/// Returns the root directory of the package store for a given scope.
142///
143/// Store Locations:
144/// - `User`: `~/.zoi/pkgs/store/`
145/// - `System`: `/var/lib/zoi/pkgs/store/` (Linux) or `C:\ProgramData\zoi\pkgs\store` (Windows)
146/// - `Project`: `./.zoi/pkgs/store/` (Relative to current project root)
147pub fn get_store_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
148    match scope {
149        crate::types::Scope::User => {
150            let home_dir =
151                get_user_home().ok_or_else(|| anyhow!("Could not find home directory."))?;
152            Ok(crate::sysroot::apply_sysroot(
153                home_dir.join(".zoi").join("pkgs").join("store"),
154            ))
155        }
156        crate::types::Scope::System => {
157            if cfg!(target_os = "windows") {
158                Ok(crate::sysroot::apply_sysroot(PathBuf::from(
159                    "C:\\ProgramData\\zoi\\pkgs\\store",
160                )))
161            } else {
162                Ok(crate::sysroot::apply_sysroot(PathBuf::from(
163                    "/var/lib/zoi/pkgs/store",
164                )))
165            }
166        }
167        crate::types::Scope::Project => {
168            let current_dir = std::env::current_dir()?;
169            Ok(current_dir.join(".zoi").join("pkgs").join("store"))
170        }
171    }
172}
173
174pub fn get_db_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
175    match scope {
176        crate::types::Scope::User => {
177            let home_dir =
178                get_user_home().ok_or_else(|| anyhow!("Could not find home directory."))?;
179            Ok(crate::sysroot::apply_sysroot(
180                home_dir.join(".zoi").join("pkgs").join("db"),
181            ))
182        }
183        crate::types::Scope::System => {
184            if cfg!(target_os = "windows") {
185                Ok(crate::sysroot::apply_sysroot(PathBuf::from(
186                    "C:\\ProgramData\\zoi\\pkgs\\db",
187                )))
188            } else {
189                Ok(crate::sysroot::apply_sysroot(PathBuf::from(
190                    "/var/lib/zoi/pkgs/db",
191                )))
192            }
193        }
194        crate::types::Scope::Project => {
195            let current_dir = std::env::current_dir()?;
196            Ok(current_dir.join(".zoi").join("pkgs").join("db"))
197        }
198    }
199}
200
201pub fn get_git_base_dir(scope: crate::types::Scope) -> Result<PathBuf> {
202    match scope {
203        crate::types::Scope::User => {
204            let home_dir =
205                get_user_home().ok_or_else(|| anyhow!("Could not find home directory."))?;
206            Ok(crate::sysroot::apply_sysroot(
207                home_dir.join(".zoi").join("pkgs").join("git"),
208            ))
209        }
210        crate::types::Scope::System => {
211            if cfg!(target_os = "windows") {
212                Ok(crate::sysroot::apply_sysroot(PathBuf::from(
213                    "C:\\ProgramData\\zoi\\pkgs\\git",
214                )))
215            } else {
216                Ok(crate::sysroot::apply_sysroot(PathBuf::from(
217                    "/var/lib/zoi/pkgs/git",
218                )))
219            }
220        }
221        crate::types::Scope::Project => {
222            let current_dir = std::env::current_dir()?;
223            Ok(current_dir.join(".zoi").join("pkgs").join("git"))
224        }
225    }
226}
227
228/// Generates a unique, origin-aware ID for a package.
229///
230/// This ID prevents collisions between packages with the same name that reside
231/// in different registries or repository tiers.
232///
233/// ID Format: `#{registry-handle}@{repo-path}/{package-name}`
234/// Hashed Result: First 32 characters of the SHA-512 hash of the ID string.
235pub fn generate_package_id(registry_handle: &str, repo_path: &str, package_name: &str) -> String {
236    let format_string = format!("#{}@{}/{}", registry_handle, repo_path, package_name);
237    let mut hasher = Sha512::new();
238    hasher.update(format_string.as_bytes());
239    let result = hasher.finalize();
240    let hex_string = hex::encode(result);
241    hex_string[..32].to_string()
242}
243
244/// Generates a unique ID for a package including its version.
245pub fn generate_versioned_package_id(
246    registry_handle: &str,
247    repo_path: &str,
248    package_name: &str,
249    version: &str,
250) -> String {
251    let format_string = format!(
252        "#{}@{}/{}@{}",
253        registry_handle, repo_path, package_name, version
254    );
255    let mut hasher = Sha512::new();
256    hasher.update(format_string.as_bytes());
257    let result = hasher.finalize();
258    let hex_string = hex::encode(result);
259    hex_string[..32].to_string()
260}
261
262/// Creates the directory name for the package in the store.
263/// Format: `{hash}-{name}`
264pub fn get_package_dir_name(package_id: &str, package_name: &str) -> String {
265    format!("{}-{}", package_id, package_name)
266}
267
268pub fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
269    let src = if src.as_os_str().is_empty() {
270        Path::new(".")
271    } else {
272        src
273    };
274    fs::create_dir_all(dst)?;
275    for entry in fs::read_dir(src)? {
276        let entry = entry?;
277        let ty = entry.file_type()?;
278        if ty.is_dir() {
279            copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
280        } else {
281            fs::copy(entry.path(), dst.join(entry.file_name()))?;
282        }
283    }
284    Ok(())
285}
286
287/// Performs a jittered exponential backoff sleep.
288///
289/// Used during network retries to prevent thundering herd problems and
290/// improve reliability on unstable connections.
291pub fn retry_backoff_sleep(attempt: u32) {
292    let base_ms = 500u64.saturating_mul(1u64 << (attempt.saturating_sub(1)));
293    let jitter = (std::time::SystemTime::now()
294        .duration_since(std::time::UNIX_EPOCH)
295        .unwrap_or(Duration::from_secs(0))
296        .subsec_millis()
297        % 200) as u64;
298    let sleep_ms = (base_ms + jitter).min(8000);
299    std::thread::sleep(Duration::from_millis(sleep_ms));
300}
301
302pub fn get_linux_distribution_info() -> Option<HashMap<String, String>> {
303    let path = crate::sysroot::apply_sysroot("/etc/os-release");
304    if let Ok(contents) = fs::read_to_string(path) {
305        let info: HashMap<String, String> = contents
306            .lines()
307            .filter_map(|line| {
308                let mut parts = line.splitn(2, '=');
309                let key = parts.next()?;
310                let value = parts.next()?.trim_matches('"').to_string();
311                if key.is_empty() {
312                    None
313                } else {
314                    Some((key.to_string(), value))
315                }
316            })
317            .collect();
318        if info.is_empty() { None } else { Some(info) }
319    } else {
320        None
321    }
322}
323
324/// Detects the general family of a Linux distribution (e.g. "debian", "arch", "fedora").
325///
326/// This is used to map specific distributions to their primary package manager
327/// and standard filesystem locations.
328///
329/// Strategy:
330/// - ID_LIKE Check: We first check the `ID_LIKE` field in `/etc/os-release`.
331///   This is the most reliable way to identify derivatives (e.g. Ubuntu is `debian`).
332/// - Direct ID Match: If `ID_LIKE` is missing, we fall back to the primary `ID`.
333/// - Normalization: We group similar distros under a common "family" key
334///   to simplify downstream logic (e.g. Rocky, Alma, and CentOS all map to `fedora`
335///   because they share the DNF/RPM ecosystem).
336pub fn get_linux_distro_family() -> Option<String> {
337    if is_zoios() {
338        return Some("zoios".to_string());
339    }
340    if let Some(info) = get_linux_distribution_info() {
341        if let Some(id_like) = info.get("ID_LIKE") {
342            let families: Vec<&str> = id_like.split_whitespace().collect();
343            if families.contains(&"debian") {
344                return Some("debian".to_string());
345            }
346            if families.contains(&"arch") {
347                return Some("arch".to_string());
348            }
349            if families.contains(&"fedora") {
350                return Some("fedora".to_string());
351            }
352            if families.contains(&"rhel") {
353                return Some("fedora".to_string());
354            }
355            if families.contains(&"suse") {
356                return Some("suse".to_string());
357            }
358            if families.contains(&"gentoo") {
359                return Some("gentoo".to_string());
360            }
361        }
362        if let Some(id) = info.get("ID") {
363            return match id.as_str() {
364                "debian" | "ubuntu" | "linuxmint" | "pop" | "kali" | "kubuntu" | "lubuntu"
365                | "xubuntu" | "zorin" | "elementary" => Some("debian".to_string()),
366                "arch" | "manjaro" | "cachyos" | "endeavouros" | "garuda" => {
367                    Some("arch".to_string())
368                }
369                "fedora" | "centos" | "rhel" | "rocky" | "almalinux" => Some("fedora".to_string()),
370                "opensuse" | "opensuse-tumbleweed" | "opensuse-leap" => Some("suse".to_string()),
371                "gentoo" => Some("gentoo".to_string()),
372                "alpine" => Some("alpine".to_string()),
373                "void" => Some("void".to_string()),
374                "solus" => Some("solus".to_string()),
375                "guix" => Some("guix".to_string()),
376                _ => None,
377            };
378        }
379    }
380    None
381}
382
383pub fn get_linux_distribution() -> Option<String> {
384    get_linux_distribution_info().and_then(|info| info.get("ID").cloned())
385}
386
387/// Returns true if the current system is a ZoiOS-based distribution (like Parlex).
388pub fn is_zoios() -> bool {
389    if let Some(info) = get_linux_distribution_info() {
390        if let Some(id) = info.get("ID")
391            && (id == "zoios" || id == "parlex")
392        {
393            return true;
394        }
395        if let Some(id_like) = info.get("ID_LIKE")
396            && id_like.split_whitespace().any(|s| s == "zoios")
397        {
398            return true;
399        }
400    }
401    false
402}
403
404/// Resolves the default installation scope based on the current environment.
405pub fn resolve_fallback_scope() -> crate::types::Scope {
406    if std::path::Path::new("zoi.lua").exists() || std::path::Path::new("zoi.yaml").exists() {
407        crate::types::Scope::Project
408    } else if is_zoios() {
409        crate::types::Scope::System
410    } else {
411        crate::types::Scope::User
412    }
413}
414
415pub fn get_desktop_environment() -> Option<String> {
416    if cfg!(target_os = "windows") {
417        return Some("windows".to_string());
418    }
419    if let Ok(de) = std::env::var("XDG_CURRENT_DESKTOP")
420        && !de.is_empty()
421    {
422        return Some(de.to_lowercase());
423    }
424    if let Ok(ds) = std::env::var("DESKTOP_SESSION")
425        && !ds.is_empty()
426    {
427        return Some(ds.to_lowercase());
428    }
429    None
430}
431
432pub fn get_display_server() -> Option<String> {
433    if cfg!(target_os = "windows") {
434        return Some("windows".to_string());
435    }
436    if cfg!(target_os = "macos") {
437        return Some("quartz".to_string());
438    }
439    if let Ok(st) = std::env::var("XDG_SESSION_TYPE")
440        && !st.is_empty()
441    {
442        return Some(st.to_lowercase());
443    }
444    None
445}
446
447pub fn get_kernel_version() -> Option<String> {
448    if cfg!(unix) {
449        let output = Command::new("uname").arg("-r").output().ok()?;
450        if output.status.success() {
451            return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
452        }
453    } else if cfg!(target_os = "windows") {
454        let output = Command::new("pwsh")
455            .arg("-Command")
456            .arg("(Get-CimInstance Win32_OperatingSystem).Version")
457            .output()
458            .ok()?;
459        if output.status.success() {
460            return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
461        }
462    }
463    None
464}
465
466pub fn get_distro_version() -> Option<String> {
467    if let Some(info) = get_linux_distribution_info()
468        && let Some(vid) = info.get("VERSION_ID")
469    {
470        return Some(vid.clone());
471    }
472    if cfg!(target_os = "macos") {
473        let output = Command::new("sw_vers")
474            .arg("-productVersion")
475            .output()
476            .ok()?;
477        if output.status.success() {
478            return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
479        }
480    } else if cfg!(target_os = "windows") {
481        let output = Command::new("pwsh")
482            .arg("-Command")
483            .arg("(Get-CimInstance Win32_OperatingSystem).Version")
484            .output()
485            .ok()?;
486        if output.status.success() {
487            return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
488        }
489    }
490    None
491}
492
493pub fn get_cpu_info() -> Option<String> {
494    if cfg!(target_os = "linux") {
495        if let Ok(cpuinfo) = fs::read_to_string("/proc/cpuinfo") {
496            for line in cpuinfo.lines() {
497                if line.starts_with("model name")
498                    && let Some((_, model)) = line.split_once(':')
499                {
500                    return Some(model.trim().to_string());
501                }
502            }
503        }
504    } else if cfg!(target_os = "macos") {
505        let output = Command::new("sysctl")
506            .arg("-n")
507            .arg("machdep.cpu.brand_string")
508            .output()
509            .ok()?;
510        if output.status.success() {
511            return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
512        }
513    } else if cfg!(target_os = "windows") {
514        let output = Command::new("pwsh")
515            .arg("-Command")
516            .arg("(Get-CimInstance Win32_Processor).Name")
517            .output()
518            .ok()?;
519        if output.status.success() {
520            return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
521        }
522    }
523    None
524}
525
526pub fn get_gpu_info() -> Option<String> {
527    if cfg!(target_os = "linux") {
528        if let Ok(output) = Command::new("lspci").output()
529            && output.status.success()
530        {
531            let stdout = String::from_utf8_lossy(&output.stdout);
532            for line in stdout.lines() {
533                if (line.contains("VGA compatible controller") || line.contains("3D controller"))
534                    && let Some((_, model)) = line.split_once(": ")
535                {
536                    return Some(model.trim().to_string());
537                }
538            }
539        }
540    } else if cfg!(target_os = "macos") {
541        let output = Command::new("system_profiler")
542            .arg("SPDisplaysDataType")
543            .output()
544            .ok()?;
545        if output.status.success() {
546            let stdout = String::from_utf8_lossy(&output.stdout);
547            for line in stdout.lines() {
548                if line.trim().starts_with("Chipset Model:")
549                    && let Some((_, model)) = line.split_once(':')
550                {
551                    return Some(model.trim().to_string());
552                }
553            }
554        }
555    } else if cfg!(target_os = "windows") {
556        let output = Command::new("pwsh")
557            .arg("-Command")
558            .arg("(Get-CimInstance Win32_VideoController).Name")
559            .output()
560            .ok()?;
561        if output.status.success() {
562            return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
563        }
564    }
565    None
566}
567
568/// Identifies the primary package manager for the current operating system.
569///
570/// This is used to resolve `native:` dependencies.
571pub fn get_native_package_manager() -> Option<String> {
572    let os = std::env::consts::OS;
573    match os {
574        "linux" => get_linux_distro_family()
575            .map(|family| {
576                match family.as_str() {
577                    "debian" => "apt",
578                    "arch" => "pacman",
579                    "fedora" => "dnf",
580                    "suse" => "zypper",
581                    "gentoo" => "portage",
582                    "alpine" => "apk",
583                    "void" => "xbps-install",
584                    "solus" => "eopkg",
585                    "guix" => "guix",
586                    "zoios" => "zoi",
587                    _ => "unknown",
588                }
589                .to_string()
590            })
591            .filter(|s| s != "unknown"),
592        "macos" => {
593            if command_exists("brew") {
594                Some("brew".to_string())
595            } else if command_exists("port") {
596                Some("macports".to_string())
597            } else {
598                None
599            }
600        }
601        "windows" => {
602            if command_exists("scoop") {
603                Some("scoop".to_string())
604            } else if command_exists("choco") {
605                Some("choco".to_string())
606            } else if command_exists("winget") {
607                Some("winget".to_string())
608            } else {
609                None
610            }
611        }
612        _ => None,
613    }
614}
615
616/// Scans the system for all supported package managers.
617///
618/// This provides the list of available managers shown in `zoi info` and
619/// used to validate `manager:` prefixes in dependency strings.
620pub fn get_all_available_package_managers() -> Vec<String> {
621    let mut managers = Vec::new();
622    let all_possible_managers = [
623        "apt",
624        "pacman",
625        "yay",
626        "paru",
627        "pikaur",
628        "trizen",
629        "dnf",
630        "yum",
631        "zypper",
632        "portage",
633        "apk",
634        "snap",
635        "flatpak",
636        "nix",
637        "brew",
638        "port",
639        "scoop",
640        "choco",
641        "winget",
642        "pkg",
643        "pkg_add",
644        "xbps-install",
645        "eopkg",
646        "guix",
647        "mas",
648    ];
649
650    for manager in &all_possible_managers {
651        if command_exists(manager) {
652            managers.push(manager.to_string());
653        }
654    }
655    managers.sort();
656    managers.dedup();
657    managers
658}
659
660pub fn format_bytes(bytes: u64) -> String {
661    const KIB: u64 = 1024;
662    const MIB: u64 = 1024 * KIB;
663    const GIB: u64 = 1024 * MIB;
664    if bytes >= GIB {
665        format!("{:.2} GiB", bytes as f64 / GIB as f64)
666    } else if bytes >= MIB {
667        format!("{:.2} MiB", bytes as f64 / MIB as f64)
668    } else if bytes >= KIB {
669        format!("{:.2} KiB", bytes as f64 / KIB as f64)
670    } else {
671        format!("{} B", bytes)
672    }
673}
674
675pub fn format_size_diff(diff: i64) -> String {
676    if diff == 0 {
677        return "0 B".to_string();
678    }
679    let sign = if diff > 0 { "+" } else { "-" };
680    let bytes = diff.unsigned_abs();
681    format!("{} {}", sign, format_bytes(bytes))
682}
683
684/// Verifies that a given path is "Safe" and doesn't attempt to escape the base directory.
685///
686/// This is a critical security check against "Path Traversal" attacks in
687/// package archives or Lua scripts.
688pub fn is_safe_path(base: &Path, path: &Path) -> bool {
689    let base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
690    let joined = if path.is_absolute() {
691        path.to_path_buf()
692    } else {
693        base.join(path)
694    };
695    let mut normalized = PathBuf::new();
696    for component in joined.components() {
697        match component {
698            std::path::Component::Prefix(_) => normalized.push(component),
699            std::path::Component::RootDir => normalized.push(component),
700            std::path::Component::CurDir => {}
701            std::path::Component::ParentDir => {
702                if !normalized.pop() {
703                    return false;
704                }
705            }
706            std::path::Component::Normal(p) => normalized.push(p),
707        }
708    }
709    normalized.starts_with(&base)
710}
711
712pub fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
713    if link.exists() || link.is_symlink() {
714        fs::remove_file(link)?;
715    }
716    #[cfg(unix)]
717    {
718        std::os::unix::fs::symlink(target, link)
719    }
720    #[cfg(windows)]
721    {
722        if std::os::windows::fs::symlink_file(target, link).is_err() {
723            if fs::hard_link(target, link).is_err() {
724                fs::copy(target, link)?;
725            }
726        }
727        Ok(())
728    }
729}
730
731pub fn is_admin() -> bool {
732    #[cfg(unix)]
733    {
734        nix::unistd::getuid().is_root()
735    }
736    #[cfg(windows)]
737    {
738        false
739    }
740}
741
742pub fn run_shell_command(command_str: &str) -> anyhow::Result<()> {
743    let status = if cfg!(target_os = "windows") {
744        Command::new("pwsh")
745            .arg("-Command")
746            .arg(command_str)
747            .status()?
748    } else {
749        Command::new("bash").arg("-c").arg(command_str).status()?
750    };
751    if !status.success() {
752        return Err(anyhow!("Command failed: {}", command_str));
753    }
754    Ok(())
755}
756
757pub fn run_shell_command_quietly(command_str: &str) -> anyhow::Result<()> {
758    let status = if cfg!(target_os = "windows") {
759        Command::new("pwsh")
760            .arg("-Command")
761            .arg(command_str)
762            .stdout(std::process::Stdio::null())
763            .stderr(std::process::Stdio::null())
764            .status()?
765    } else {
766        Command::new("bash")
767            .arg("-c")
768            .arg(command_str)
769            .stdout(std::process::Stdio::null())
770            .stderr(std::process::Stdio::null())
771            .status()?
772    };
773    if !status.success() {
774        return Err(anyhow!("Command failed: {}", command_str));
775    }
776    Ok(())
777}
778
779pub fn is_mini_mode() -> bool {
780    std::env::var("ZOI_MINI_MODE").is_ok_and(|v| v == "1")
781}
782
783pub fn ask_for_confirmation(prompt: &str, yes: bool) -> bool {
784    if yes {
785        return true;
786    }
787    if std::env::var("ZOI_TEST").is_ok() || !stdin().is_tty() {
788        return false;
789    }
790    print!("{} [y/N]: ", prompt);
791    let _ = stdout().flush();
792    let mut input = String::new();
793    if stdin().read_line(&mut input).is_err() {
794        return false;
795    }
796    input.trim().eq_ignore_ascii_case("y")
797}
798
799pub fn set_path_read_only(path: &Path) -> anyhow::Result<()> {
800    if !path.exists() {
801        return Ok(());
802    }
803    for entry in walkdir::WalkDir::new(path) {
804        let entry = entry?;
805        let mut perms = fs::metadata(entry.path())?.permissions();
806        if !perms.readonly() {
807            perms.set_readonly(true);
808            fs::set_permissions(entry.path(), perms)?;
809        }
810    }
811    Ok(())
812}
813
814pub fn set_path_writable(path: &Path) -> anyhow::Result<()> {
815    if !path.exists() {
816        return Ok(());
817    }
818    for entry in walkdir::WalkDir::new(path) {
819        let entry = entry?;
820        let mut perms = fs::metadata(entry.path())?.permissions();
821        if perms.readonly() {
822            #[cfg(unix)]
823            {
824                use std::os::unix::fs::PermissionsExt;
825                let mode = perms.mode();
826                perms.set_mode(mode | 0o200);
827            }
828            #[cfg(not(unix))]
829            {
830                perms.set_readonly(false);
831            }
832            fs::set_permissions(entry.path(), perms)?;
833        }
834    }
835    Ok(())
836}
837
838#[cfg(unix)]
839pub fn set_path_owner(path: &Path, owner: &str, group: &str) -> anyhow::Result<()> {
840    use nix::unistd::{Gid, Group, Uid, User, chown};
841    let uid = if let Ok(u) = owner.parse::<u32>() {
842        Some(Uid::from_raw(u))
843    } else if !owner.is_empty() {
844        Some(
845            User::from_name(owner)
846                .map_err(|e| anyhow!("Error looking up user '{}': {}", owner, e))?
847                .ok_or_else(|| anyhow!("User not found: {}", owner))?
848                .uid,
849        )
850    } else {
851        None
852    };
853    let gid = if let Ok(g) = group.parse::<u32>() {
854        Some(Gid::from_raw(g))
855    } else if !group.is_empty() {
856        Some(
857            Group::from_name(group)
858                .map_err(|e| anyhow!("Error looking up group '{}': {}", group, e))?
859                .ok_or_else(|| anyhow!("Group not found: {}", group))?
860                .gid,
861        )
862    } else {
863        None
864    };
865    chown(path, uid, gid).map_err(|e| anyhow!("Failed to chown '{}': {}", path.display(), e))?;
866    Ok(())
867}
868
869pub fn is_platform_compatible(current_platform: &str, allowed_platforms: &[String]) -> bool {
870    let os_part = current_platform
871        .split('-')
872        .next()
873        .unwrap_or(current_platform);
874    let os = match os_part {
875        "darwin" => "macos",
876        other => other,
877    };
878    allowed_platforms.iter().any(|p| {
879        if let Some(rest) = p.strip_prefix("ci:") {
880            let target = rest.split(':').next().unwrap_or_default();
881            target == current_platform || target == os
882        } else {
883            let p_norm = if p == "darwin" { "macos" } else { p };
884            p_norm == "all" || p_norm == os || p_norm == current_platform
885        }
886    })
887}
888
889pub fn check_license(license: &str) {
890    if license.is_empty() {
891        return;
892    }
893    if license.eq_ignore_ascii_case("Proprietary") || license.eq_ignore_ascii_case("Unknown") {
894        return;
895    }
896    if let Ok(expr) = spdx::Expression::parse(license)
897        && !expr.evaluate(|req| match req.license {
898            spdx::LicenseItem::Spdx { id, .. } => id.is_osi_approved(),
899            spdx::LicenseItem::Other { .. } => false,
900        })
901    {}
902}
903
904pub fn confirm_untrusted_source(
905    source_type: &crate::types::SourceType,
906    yes: bool,
907) -> anyhow::Result<()> {
908    if is_mini_mode() {
909        return Ok(());
910    }
911    if source_type == &crate::types::SourceType::OfficialRepo {
912        return Ok(());
913    }
914    let warning_message = match source_type {
915        crate::types::SourceType::UntrustedRepo(repo) => {
916            format!(
917                "The package from repository '@{}' is not an official Zoi repository.",
918                repo
919            )
920        }
921        crate::types::SourceType::LocalFile => "You are installing from a local file.".to_string(),
922        crate::types::SourceType::Url => "You are installing from a remote URL. This script will be executed with your user's permissions, which could lead to remote code execution if the source is malicious.".to_string(),
923        crate::types::SourceType::GitRepo(repo) => format!("You are installing from an external git repository '{}'. This script will be executed with your user's permissions.", repo),
924        _ => return Ok(()),
925    };
926    println!(
927        "\n{}: {}",
928        "SECURITY WARNING".yellow().bold(),
929        warning_message
930    );
931    if ask_for_confirmation(
932        "This source is not trusted. Are you sure you want to continue?",
933        yes,
934    ) {
935        Ok(())
936    } else {
937        Err(anyhow!("Operation aborted by user."))
938    }
939}
940
941pub fn expand_placeholders(
942    path: &str,
943    version_dir: &Path,
944    scope: crate::types::Scope,
945) -> Result<String> {
946    let mut expanded = path.to_string();
947    expanded = expanded.replace("${pkgstore}", &version_dir.to_string_lossy());
948    expanded = expanded.replace(
949        "${usrroot}",
950        &crate::sysroot::apply_sysroot(PathBuf::from("/")).to_string_lossy(),
951    );
952    if let Some(home_dir) = get_user_home() {
953        expanded = expanded.replace("${usrhome}", &home_dir.to_string_lossy());
954    }
955
956    let applications_dir = match scope {
957        crate::types::Scope::System => PathBuf::from("/Applications"),
958        crate::types::Scope::User => get_user_home()
959            .map(|h| h.join("Applications"))
960            .unwrap_or_else(|| PathBuf::from("/Applications")),
961        crate::types::Scope::Project => std::env::current_dir()
962            .unwrap_or_default()
963            .join("Applications"),
964    };
965    expanded = expanded.replace("${applications}", &applications_dir.to_string_lossy());
966
967    Ok(expanded)
968}
969
970pub fn expand_tilde<P: AsRef<Path>>(path: P) -> PathBuf {
971    let path = path.as_ref();
972    if !path.starts_with("~") {
973        return path.to_path_buf();
974    }
975    if let Some(home_dir) = get_user_home() {
976        if path == Path::new("~") {
977            return home_dir;
978        }
979        if let Ok(stripped) = path.strip_prefix("~/") {
980            return home_dir.join(stripped);
981        }
982    }
983    path.to_path_buf()
984}
985
986pub fn get_current_shell() -> Option<Shell> {
987    if cfg!(windows) {
988        return Some(Shell::PowerShell);
989    }
990    if let Ok(shell_path) = std::env::var("SHELL") {
991        let shell_name = Path::new(&shell_path).file_name()?.to_str()?;
992        match shell_name {
993            "bash" => Some(Shell::Bash),
994            "zsh" => Some(Shell::Zsh),
995            "fish" => Some(Shell::Fish),
996            "elvish" => Some(Shell::Elvish),
997            "pwsh" => Some(Shell::PowerShell),
998            _ => None,
999        }
1000    } else {
1001        None
1002    }
1003}