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        crate::types::Scope::Project
634    } else if is_zoios() {
635        crate::types::Scope::System
636    } else {
637        crate::types::Scope::User
638    }
639}
640
641/// Returns the name of the current desktop environment (e.g. "gnome", "kde").
642pub fn get_desktop_environment() -> Option<String> {
643    if cfg!(target_os = "windows") {
644        return Some("windows".to_string());
645    }
646    if let Ok(de) = std::env::var("XDG_CURRENT_DESKTOP")
647        && !de.is_empty()
648    {
649        return Some(de.to_lowercase());
650    }
651    if let Ok(ds) = std::env::var("DESKTOP_SESSION")
652        && !ds.is_empty()
653    {
654        return Some(ds.to_lowercase());
655    }
656    None
657}
658
659/// Returns the name of the current display server (e.g. "wayland", "x11").
660pub fn get_display_server() -> Option<String> {
661    if cfg!(target_os = "windows") {
662        return Some("windows".to_string());
663    }
664    if cfg!(target_os = "macos") {
665        return Some("quartz".to_string());
666    }
667    if let Ok(st) = std::env::var("XDG_SESSION_TYPE")
668        && !st.is_empty()
669    {
670        return Some(st.to_lowercase());
671    }
672    None
673}
674
675/// Returns the version of the operating system kernel.
676pub fn get_kernel_version() -> Option<String> {
677    if cfg!(unix) {
678        let output = Command::new("uname").arg("-r").output().ok()?;
679        if output.status.success() {
680            return Some(
681                String::from_utf8_lossy(&output.stdout).trim().to_string()
682            );
683        }
684    } else if cfg!(target_os = "windows") {
685        let output = Command::new("pwsh")
686            .arg("-Command")
687            .arg("(Get-CimInstance Win32_OperatingSystem).Version")
688            .output()
689            .ok()?;
690        if output.status.success() {
691            return Some(
692                String::from_utf8_lossy(&output.stdout).trim().to_string()
693            );
694        }
695    }
696    None
697}
698
699/// Returns the name of the system's init system (e.g. "systemd", "openrc").
700pub fn get_init_system() -> Option<String> {
701    if let Ok(val) = std::env::var("ZOI_INIT") {
702        return Some(val.to_lowercase());
703    }
704
705    let run_systemd =
706        crate::sysroot::apply_sysroot(PathBuf::from("/run/systemd/system"));
707    if run_systemd.exists() {
708        return Some("systemd".to_string());
709    }
710
711    let run_openrc =
712        crate::sysroot::apply_sysroot(PathBuf::from("/run/openrc"));
713    if run_openrc.exists() {
714        return Some("openrc".to_string());
715    }
716
717    let sbin_init = crate::sysroot::apply_sysroot(PathBuf::from("/sbin/init"));
718    if let Ok(target) = std::fs::read_link(&sbin_init) {
719        let target_str = target.to_string_lossy();
720        if target_str.contains("systemd") {
721            return Some("systemd".to_string());
722        } else if target_str.contains("openrc") {
723            return Some("openrc".to_string());
724        } else if target_str.contains("busybox") {
725            return Some("busybox".to_string());
726        }
727    }
728
729    None
730}
731
732/// Returns the name of the available privilege escalation tool (e.g. "sudo",
733/// "doas").
734pub fn get_privilege_escalator() -> Option<String> {
735    if command_exists("sudo") {
736        Some("sudo".to_string())
737    } else if command_exists("doas") {
738        Some("doas".to_string())
739    } else {
740        None
741    }
742}
743
744/// Returns the version of the current distribution.
745pub fn get_distro_version() -> Option<String> {
746    if let Some(info) = get_linux_distribution_info()
747        && let Some(vid) = info.get("VERSION_ID")
748    {
749        return Some(vid.clone());
750    }
751    if cfg!(target_os = "macos") {
752        let output = Command::new("sw_vers")
753            .arg("-productVersion")
754            .output()
755            .ok()?;
756        if output.status.success() {
757            return Some(
758                String::from_utf8_lossy(&output.stdout).trim().to_string()
759            );
760        }
761    } else if cfg!(target_os = "windows") {
762        let output = Command::new("pwsh")
763            .arg("-Command")
764            .arg("(Get-CimInstance Win32_OperatingSystem).Version")
765            .output()
766            .ok()?;
767        if output.status.success() {
768            return Some(
769                String::from_utf8_lossy(&output.stdout).trim().to_string()
770            );
771        }
772    }
773    None
774}
775
776/// Returns a short description of the system's CPU.
777pub fn get_cpu_info() -> Option<String> {
778    if cfg!(target_os = "linux") {
779        if let Ok(cpuinfo) = fs::read_to_string("/proc/cpuinfo") {
780            for line in cpuinfo.lines() {
781                if line.starts_with("model name")
782                    && let Some((_, model)) = line.split_once(':')
783                {
784                    return Some(model.trim().to_string());
785                }
786            }
787        }
788    } else if cfg!(target_os = "macos") {
789        let output = Command::new("sysctl")
790            .arg("-n")
791            .arg("machdep.cpu.brand_string")
792            .output()
793            .ok()?;
794        if output.status.success() {
795            return Some(
796                String::from_utf8_lossy(&output.stdout).trim().to_string()
797            );
798        }
799    } else if cfg!(target_os = "windows") {
800        let output = Command::new("pwsh")
801            .arg("-Command")
802            .arg("(Get-CimInstance Win32_Processor).Name")
803            .output()
804            .ok()?;
805        if output.status.success() {
806            return Some(
807                String::from_utf8_lossy(&output.stdout).trim().to_string()
808            );
809        }
810    }
811    None
812}
813
814/// Returns a short description of the system's GPU.
815pub fn get_gpu_info() -> Option<String> {
816    if cfg!(target_os = "linux") {
817        if let Ok(output) = Command::new("lspci").output()
818            && output.status.success()
819        {
820            let stdout = String::from_utf8_lossy(&output.stdout);
821            for line in stdout.lines() {
822                if (line.contains("VGA compatible controller")
823                    || line.contains("3D controller"))
824                    && let Some((_, model)) = line.split_once(": ")
825                {
826                    return Some(model.trim().to_string());
827                }
828            }
829        }
830    } else if cfg!(target_os = "macos") {
831        let output = Command::new("system_profiler")
832            .arg("SPDisplaysDataType")
833            .output()
834            .ok()?;
835        if output.status.success() {
836            let stdout = String::from_utf8_lossy(&output.stdout);
837            for line in stdout.lines() {
838                if line.trim().starts_with("Chipset Model:")
839                    && let Some((_, model)) = line.split_once(':')
840                {
841                    return Some(model.trim().to_string());
842                }
843            }
844        }
845    } else if cfg!(target_os = "windows") {
846        let output = Command::new("pwsh")
847            .arg("-Command")
848            .arg("(Get-CimInstance Win32_VideoController).Name")
849            .output()
850            .ok()?;
851        if output.status.success() {
852            return Some(
853                String::from_utf8_lossy(&output.stdout).trim().to_string()
854            );
855        }
856    }
857    None
858}
859
860/// Identifies the primary package manager for the current operating system.
861///
862/// This is used to resolve `native:` dependencies.
863pub fn get_native_package_manager() -> Option<String> {
864    let os = std::env::consts::OS;
865    match os {
866        "linux" => get_linux_distro_family()
867            .map(|family| {
868                match family.as_str() {
869                    "debian" => "apt",
870                    "arch" => "pacman",
871                    "fedora" => "dnf",
872                    "suse" => "zypper",
873                    "gentoo" => "portage",
874                    "alpine" => "apk",
875                    "void" => "xbps-install",
876                    "solus" => "eopkg",
877                    "guix" => "guix",
878                    "zoios" => "zoi",
879                    _ => "unknown"
880                }
881                .to_string()
882            })
883            .filter(|s| s != "unknown"),
884        "macos" => {
885            if command_exists("brew") {
886                Some("brew".to_string())
887            } else if command_exists("port") {
888                Some("macports".to_string())
889            } else {
890                None
891            }
892        }
893        "windows" => {
894            if command_exists("scoop") {
895                Some("scoop".to_string())
896            } else if command_exists("choco") {
897                Some("choco".to_string())
898            } else if command_exists("winget") {
899                Some("winget".to_string())
900            } else {
901                None
902            }
903        }
904        _ => None
905    }
906}
907
908/// Scans the system for all supported package managers.
909///
910/// This provides the list of available managers shown in `zoi info` and
911/// used to validate `manager:` prefixes in dependency strings.
912pub fn get_all_available_package_managers() -> Vec<String> {
913    let mut managers = Vec::new();
914    let all_possible_managers = [
915        "apt",
916        "pacman",
917        "yay",
918        "paru",
919        "pikaur",
920        "trizen",
921        "dnf",
922        "yum",
923        "zypper",
924        "portage",
925        "apk",
926        "snap",
927        "flatpak",
928        "nix",
929        "brew",
930        "port",
931        "scoop",
932        "choco",
933        "winget",
934        "pkg",
935        "pkg_add",
936        "xbps-install",
937        "eopkg",
938        "guix",
939        "mas"
940    ];
941
942    for manager in &all_possible_managers {
943        if command_exists(manager) {
944            managers.push(manager.to_string());
945        }
946    }
947    managers.sort();
948    managers.dedup();
949    managers
950}
951
952/// Formats a byte count into a human-readable string (e.g. "1.24 MiB").
953pub fn format_bytes(bytes: u64) -> String {
954    const KIB: u64 = 1024;
955    const MIB: u64 = 1024 * KIB;
956    const GIB: u64 = 1024 * MIB;
957    if bytes >= GIB {
958        format!("{:.2} GiB", bytes as f64 / GIB as f64)
959    } else if bytes >= MIB {
960        format!("{:.2} MiB", bytes as f64 / MIB as f64)
961    } else if bytes >= KIB {
962        format!("{:.2} KiB", bytes as f64 / KIB as f64)
963    } else {
964        format!("{bytes} B")
965    }
966}
967
968/// Formats a size difference into a signed human-readable string (e.g. "+50
969/// B").
970pub fn format_size_diff(diff: i64) -> String {
971    if diff == 0 {
972        return "0 B".to_string();
973    }
974    let sign = if diff > 0 { "+" } else { "-" };
975    let bytes = diff.unsigned_abs();
976    format!("{}{}", sign, format_bytes(bytes))
977}
978
979/// Verifies that a given path is "Safe" and doesn't attempt to escape the base
980/// directory.
981///
982/// This is a critical security check against "Path Traversal" attacks in
983/// package archives or Lua scripts.
984pub fn is_safe_path(base: &Path, path: &Path) -> bool {
985    let base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
986    let joined = if path.is_absolute() {
987        path.to_path_buf()
988    } else {
989        base.join(path)
990    };
991    let mut normalized = PathBuf::new();
992    for component in joined.components() {
993        match component {
994            std::path::Component::Prefix(_) | std::path::Component::RootDir => {
995                normalized.push(component);
996            }
997            std::path::Component::CurDir => {}
998            std::path::Component::ParentDir => {
999                if !normalized.pop() {
1000                    return false;
1001                }
1002            }
1003            std::path::Component::Normal(p) => normalized.push(p)
1004        }
1005    }
1006    normalized.starts_with(&base)
1007}
1008
1009/// Creates a symbolic link for a file, handling platform-specific requirements.
1010///
1011/// # Errors
1012///
1013/// Returns an error if the symlink operation fails.
1014pub fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
1015    if link.exists() || link.is_symlink() {
1016        fs::remove_file(link)?;
1017    }
1018    #[cfg(unix)]
1019    {
1020        std::os::unix::fs::symlink(target, link)
1021    }
1022    #[cfg(windows)]
1023    {
1024        if std::os::windows::fs::symlink_file(target, link).is_err() {
1025            if fs::hard_link(target, link).is_err() {
1026                fs::copy(target, link)?;
1027            }
1028        }
1029        Ok(())
1030    }
1031}
1032
1033/// Checks if the current process has administrative privileges.
1034pub fn is_admin() -> bool {
1035    #[cfg(unix)]
1036    {
1037        nix::unistd::getuid().is_root()
1038    }
1039    #[cfg(windows)]
1040    {
1041        false
1042    }
1043}
1044
1045/// Executes a shell command and returns an error if it fails.
1046///
1047/// # Errors
1048///
1049/// Returns an error if the command fails to execute or returns a non-zero exit
1050/// status.
1051pub fn run_shell_command(command_str: &str) -> anyhow::Result<()> {
1052    let status = if cfg!(target_os = "windows") {
1053        Command::new("pwsh")
1054            .arg("-Command")
1055            .arg(command_str)
1056            .status()?
1057    } else {
1058        Command::new("bash").arg("-c").arg(command_str).status()?
1059    };
1060    if !status.success() {
1061        return Err(anyhow!("Command failed: {command_str}"));
1062    }
1063    Ok(())
1064}
1065
1066/// Executes a shell command quietly (suppressing output) and returns an error
1067/// if it fails.
1068///
1069/// # Errors
1070///
1071/// Returns an error if the command fails to execute or returns a non-zero exit
1072/// status.
1073pub fn run_shell_command_quietly(command_str: &str) -> anyhow::Result<()> {
1074    let status = if cfg!(target_os = "windows") {
1075        Command::new("pwsh")
1076            .arg("-Command")
1077            .arg(command_str)
1078            .stdout(std::process::Stdio::null())
1079            .stderr(std::process::Stdio::null())
1080            .status()?
1081    } else {
1082        Command::new("bash")
1083            .arg("-c")
1084            .arg(command_str)
1085            .stdout(std::process::Stdio::null())
1086            .stderr(std::process::Stdio::null())
1087            .status()?
1088    };
1089    if !status.success() {
1090        return Err(anyhow!("Command failed: {command_str}"));
1091    }
1092    Ok(())
1093}
1094
1095/// Returns true if Zoi is running in "Mini" mode (lightweight, zero-sync).
1096pub fn is_mini_mode() -> bool {
1097    std::env::var("ZOI_MINI_MODE").is_ok_and(|v| v == "1")
1098}
1099
1100/// Prompts the user for confirmation (y/N) unless the `yes` flag is set.
1101pub fn ask_for_confirmation(prompt: &str, yes: bool) -> bool {
1102    if yes {
1103        return true;
1104    }
1105    if std::env::var("ZOI_TEST").is_ok() || !stdin().is_tty() {
1106        return false;
1107    }
1108    print!("{prompt} [y/N]: ");
1109    let _ = stdout().flush();
1110    let mut input = String::new();
1111    if stdin().read_line(&mut input).is_err() {
1112        return false;
1113    }
1114    input.trim().eq_ignore_ascii_case("y")
1115}
1116
1117/// Recursively sets a directory and its contents to be read-only.
1118///
1119/// # Errors
1120///
1121/// Returns an error if the permission change fails.
1122pub fn set_path_read_only(path: &Path) -> anyhow::Result<()> {
1123    if !path.exists() {
1124        return Ok(());
1125    }
1126    for entry in walkdir::WalkDir::new(path) {
1127        let entry = entry?;
1128        let mut perms = fs::metadata(entry.path())?.permissions();
1129        if !perms.readonly() {
1130            perms.set_readonly(true);
1131            fs::set_permissions(entry.path(), perms)?;
1132        }
1133    }
1134    Ok(())
1135}
1136
1137/// Recursively ensures a directory and its contents are writable.
1138///
1139/// # Errors
1140///
1141/// Returns an error if the permission change fails.
1142pub fn set_path_writable(path: &Path) -> anyhow::Result<()> {
1143    if !path.exists() {
1144        return Ok(());
1145    }
1146    for entry in walkdir::WalkDir::new(path) {
1147        let entry = entry?;
1148        let mut perms = fs::metadata(entry.path())?.permissions();
1149        if perms.readonly() {
1150            #[cfg(unix)]
1151            {
1152                use std::os::unix::fs::PermissionsExt;
1153                let mode = perms.mode();
1154                perms.set_mode(mode | 0o200);
1155            }
1156            #[cfg(not(unix))]
1157            {
1158                perms.set_readonly(false);
1159            }
1160            fs::set_permissions(entry.path(), perms)?;
1161        }
1162    }
1163    Ok(())
1164}
1165
1166/// Sets the owner and group for a file or directory (Unix only).
1167///
1168/// # Errors
1169///
1170/// Returns an error if the owner or group lookup fails, or if the chown
1171/// operation fails.
1172#[cfg(unix)]
1173pub fn set_path_owner(
1174    path: &Path,
1175    owner: &str,
1176    group: &str
1177) -> anyhow::Result<()> {
1178    use nix::unistd::{Gid, Group, Uid, User, chown};
1179    let uid = if let Ok(u) = owner.parse::<u32>() {
1180        Some(Uid::from_raw(u))
1181    } else if !owner.is_empty() {
1182        Some(
1183            User::from_name(owner)
1184                .map_err(|e| anyhow!("Error looking up user '{owner}': {e}"))?
1185                .ok_or_else(|| anyhow!("User not found: {owner}"))?
1186                .uid
1187        )
1188    } else {
1189        None
1190    };
1191    let gid = if let Ok(g) = group.parse::<u32>() {
1192        Some(Gid::from_raw(g))
1193    } else if !group.is_empty() {
1194        Some(
1195            Group::from_name(group)
1196                .map_err(|e| anyhow!("Error looking up group '{group}': {e}"))?
1197                .ok_or_else(|| anyhow!("Group not found: {group}"))?
1198                .gid
1199        )
1200    } else {
1201        None
1202    };
1203    chown(path, uid, gid)
1204        .map_err(|e| anyhow!("Failed to chown '{}': {}", path.display(), e))?;
1205    Ok(())
1206}
1207
1208/// Checks if a target platform is compatible with a list of allowed platforms.
1209pub fn is_platform_compatible(
1210    current_platform: &str,
1211    allowed_platforms: &[String]
1212) -> bool {
1213    let os_part = current_platform
1214        .split('-')
1215        .next()
1216        .unwrap_or(current_platform);
1217    let os = match os_part {
1218        "darwin" => "macos",
1219        other => other
1220    };
1221    allowed_platforms.iter().any(|p| {
1222        if let Some(rest) = p.strip_prefix("ci:") {
1223            let target = rest.split(':').next().unwrap_or_default();
1224            target == current_platform || target == os
1225        } else {
1226            let p_norm = if p == "darwin" { "macos" } else { p };
1227            p_norm == "all" || p_norm == os || p_norm == current_platform
1228        }
1229    })
1230}
1231
1232/// Validates a package license against SPDX and organizational policies.
1233pub fn check_license(license: &str) {
1234    if license.is_empty() || license.eq_ignore_ascii_case("None") {
1235        return;
1236    }
1237    if license.eq_ignore_ascii_case("Proprietary")
1238        || license.eq_ignore_ascii_case("Unknown")
1239    {
1240        return;
1241    }
1242    if is_external_license_link(license) {
1243        println!(
1244            "{} The license is available at: {}",
1245            "Warning:".yellow(),
1246            license.cyan().bold()
1247        );
1248        return;
1249    }
1250    match spdx::Expression::parse(license) {
1251        Ok(expr) => {
1252            if !expr.evaluate(|req| match req.license {
1253                spdx::LicenseItem::Spdx { id, .. } => id.is_osi_approved(),
1254                spdx::LicenseItem::Other { .. } => false
1255            }) {
1256                println!(
1257                    "{} License '{}' is not an OSI approved license.",
1258                    "Warning:".yellow(),
1259                    license.yellow().bold()
1260                );
1261            }
1262        }
1263        Err(_) => {
1264            println!(
1265                "{} Could not parse license expression '{}'. It may not be a \
1266                 valid SPDX identifier.",
1267                "Warning:".yellow(),
1268                license.yellow().bold()
1269            );
1270        }
1271    }
1272}
1273
1274/// Returns `true` if the license value is an external link (a URL) pointing to
1275/// the license text hosted elsewhere instead of an SPDX identifier.
1276fn is_external_license_link(license: &str) -> bool {
1277    let trimmed = license.trim();
1278    trimmed.starts_with("http://") || trimmed.starts_with("https://")
1279}
1280
1281/// Prompts the user to confirm installation from an untrusted (non-official)
1282/// source.
1283///
1284/// # Errors
1285///
1286/// Returns an error if the user aborts the operation.
1287pub fn confirm_untrusted_source(
1288    source_type: &crate::types::SourceType,
1289    yes: bool
1290) -> anyhow::Result<()> {
1291    if is_mini_mode() {
1292        return Ok(());
1293    }
1294    if source_type == &crate::types::SourceType::OfficialRepo {
1295        return Ok(());
1296    }
1297    let warning_message = match source_type {
1298        crate::types::SourceType::UntrustedRepo(repo) => {
1299            format!(
1300                "The package from repository '@{repo}' is not an official Zoi \
1301                 repository."
1302            )
1303        }
1304        crate::types::SourceType::LocalFile => {
1305            "You are installing from a local file.".to_string()
1306        }
1307        crate::types::SourceType::Url => {
1308            "You are installing from a remote URL. This script will be \
1309             executed with your user's permissions, which could lead to remote \
1310             code execution if the source is malicious."
1311                .to_string()
1312        }
1313        crate::types::SourceType::GitRepo(repo) => format!(
1314            "You are installing from an external git repository '{repo}'. \
1315             This script will be executed with your user's permissions."
1316        ),
1317        crate::types::SourceType::OfficialRepo => return Ok(())
1318    };
1319    println!(
1320        "\n{}: {}",
1321        "SECURITY WARNING".yellow().bold(),
1322        warning_message
1323    );
1324    if ask_for_confirmation(
1325        "This source is not trusted. Are you sure you want to continue?",
1326        yes
1327    ) {
1328        Ok(())
1329    } else {
1330        Err(anyhow!("Operation aborted by user."))
1331    }
1332}
1333
1334/// Expands standard Zoi path placeholders (e.g. `${pkgstore}`, `${usrhome}`)
1335/// in a string.
1336///
1337/// Note that `${createpkgdir}` is deliberately not expanded here: it depends
1338/// on the directory Zoi was invoked from during installation, so resolving it
1339/// at cleanup time could target an unrelated path. Pooled installations
1340/// record the absolute location for these entries instead.
1341///
1342/// # Errors
1343///
1344/// Returns an error if the current directory cannot be determined.
1345pub fn expand_placeholders(
1346    path: &str,
1347    version_dir: &Path,
1348    scope: crate::types::Scope
1349) -> Result<String> {
1350    let mut expanded = path.to_string();
1351    expanded = expanded.replace("${pkgstore}", &version_dir.to_string_lossy());
1352    expanded = expanded.replace(
1353        "${usrroot}",
1354        &crate::sysroot::apply_sysroot(PathBuf::from("/")).to_string_lossy()
1355    );
1356    if let Some(home_dir) = get_user_home() {
1357        expanded = expanded.replace("${usrhome}", &home_dir.to_string_lossy());
1358    }
1359
1360    let applications_dir = match scope {
1361        crate::types::Scope::System => PathBuf::from("/Applications"),
1362        crate::types::Scope::User => get_user_home().map_or_else(
1363            || PathBuf::from("/Applications"),
1364            |h| h.join("Applications")
1365        ),
1366        crate::types::Scope::Project => std::env::current_dir()
1367            .unwrap_or_default()
1368            .join("Applications")
1369    };
1370    expanded = expanded
1371        .replace("${applications}", &applications_dir.to_string_lossy());
1372
1373    Ok(expanded)
1374}
1375
1376/// Strips Zoi staging placeholders to sysroot-relative form for matching.
1377///
1378/// Install manifests record system files with placeholders (e.g.
1379/// `${usrroot}/usr/share/fonts/foo.ttf`), while hook triggers, file
1380/// databases, and other matchers work with sysroot-relative paths (e.g.
1381/// `usr/share/fonts/foo.ttf`). This is the canonical stripping logic shared
1382/// by all crates, it mirrors the placeholder roots used by
1383/// [`expand_placeholders`], `expand_pooled_path` in `zoi-install`, and the
1384/// staging-directory mapping in `zoi-package`, but stays scope- and
1385/// version-independent so it can be used transaction-wide:
1386///
1387/// - `${usrroot}/...` -> `...` (matches `apply_sysroot("/")` expansion,
1388///   relativized).
1389/// - `${usrhome}/...` -> `...` (stable across machines, unlike expanding to the
1390///   current home directory).
1391/// - `${pkgstore}/...` -> `pkgstore/...` (namespaced so store-internal files
1392///   never match system triggers).
1393/// - `${applications}/...` -> `...`.
1394/// - Bare staging forms (`usrroot/...`, `usrhome/...`, `pkgstore/...`) are
1395///   handled the same way for robustness.
1396/// - Everything else (including already-absolute `${createpkgdir}` records) is
1397///   returned unchanged.
1398#[must_use]
1399pub fn strip_staging_placeholder(path: &str) -> String {
1400    if let Some(rest) = path.strip_prefix("${usrroot}/") {
1401        return rest.to_string();
1402    }
1403    if let Some(rest) = path.strip_prefix("${usrroot}") {
1404        return rest.trim_start_matches('/').to_string();
1405    }
1406    if let Some(rest) = path.strip_prefix("${usrhome}/") {
1407        return rest.to_string();
1408    }
1409    if let Some(rest) = path.strip_prefix("${usrhome}") {
1410        return rest.trim_start_matches('/').to_string();
1411    }
1412    if let Some(rest) = path.strip_prefix("${pkgstore}/") {
1413        return format!("pkgstore/{rest}");
1414    }
1415    if let Some(rest) = path.strip_prefix("${pkgstore}") {
1416        return format!("pkgstore/{}", rest.trim_start_matches('/'));
1417    }
1418    if let Some(rest) = path.strip_prefix("${applications}/") {
1419        return rest.to_string();
1420    }
1421    if let Some(rest) = path.strip_prefix("${applications}") {
1422        return rest.trim_start_matches('/').to_string();
1423    }
1424    if let Some(rest) = path.strip_prefix("usrroot/") {
1425        return rest.to_string();
1426    }
1427    if let Some(rest) = path.strip_prefix("usrhome/") {
1428        return rest.to_string();
1429    }
1430    path.to_string()
1431}
1432
1433/// Expands only the location-roots (`${usrroot}`, `${usrhome}`) using the
1434/// same locations as [`expand_placeholders`].
1435///
1436/// Unlike [`expand_placeholders`], this needs neither a version directory
1437/// nor a scope, so it can be used transaction-wide when matching hook
1438/// triggers against installed files. `${pkgstore}`, `${applications}`,
1439/// `${createpkgdir}`, and `${pkgluadir}` are left untouched: the store needs
1440/// a per-package version directory to resolve, and the others are
1441/// scope-dependent.
1442#[must_use]
1443pub fn expand_staging_root_placeholders(path: &str) -> String {
1444    let mut expanded = path.to_string();
1445    expanded = expanded.replace(
1446        "${usrroot}",
1447        &crate::sysroot::apply_sysroot(PathBuf::from("/")).to_string_lossy()
1448    );
1449    if let Some(home_dir) = get_user_home() {
1450        expanded = expanded.replace("${usrhome}", &home_dir.to_string_lossy());
1451    }
1452    expanded
1453}
1454
1455/// Expands the `~` character to the user's home directory.
1456pub fn expand_tilde<P: AsRef<Path>>(path: P) -> PathBuf {
1457    let path = path.as_ref();
1458    if !path.starts_with("~") {
1459        return path.to_path_buf();
1460    }
1461    if let Some(home_dir) = get_user_home() {
1462        if path == Path::new("~") {
1463            return home_dir;
1464        }
1465        if let Ok(stripped) = path.strip_prefix("~/") {
1466            return home_dir.join(stripped);
1467        }
1468    }
1469    path.to_path_buf()
1470}
1471
1472/// Detects and returns the current shell being used.
1473pub fn get_current_shell() -> Option<Shell> {
1474    if cfg!(windows) {
1475        return Some(Shell::PowerShell);
1476    }
1477    if let Ok(shell_path) = std::env::var("SHELL") {
1478        let shell_name = Path::new(&shell_path).file_name()?.to_str()?;
1479        match shell_name {
1480            "bash" => Some(Shell::Bash),
1481            "zsh" => Some(Shell::Zsh),
1482            "fish" => Some(Shell::Fish),
1483            "elvish" => Some(Shell::Elvish),
1484            "pwsh" => Some(Shell::PowerShell),
1485            _ => None
1486        }
1487    } else {
1488        None
1489    }
1490}
1491
1492#[cfg(test)]
1493mod tests {
1494    use super::*;
1495
1496    #[test]
1497    fn detects_http_and_https_external_license_links() {
1498        assert!(is_external_license_link("https://example.com/LICENSE"));
1499        assert!(is_external_license_link("http://example.com/license"));
1500        assert!(is_external_license_link("  https://example.com/MIT.txt  "));
1501    }
1502
1503    #[test]
1504    fn does_not_treat_spdx_or_special_values_as_links() {
1505        assert!(!is_external_license_link("MIT"));
1506        assert!(!is_external_license_link("Apache-2.0"));
1507        assert!(!is_external_license_link("None"));
1508        assert!(!is_external_license_link("Proprietary"));
1509        assert!(!is_external_license_link("Unknown"));
1510    }
1511
1512    #[test]
1513    fn url_license_is_flagged_before_spdx_parsing() {
1514        assert!(is_external_license_link("https://example.com/LICENSE"));
1515        assert!(
1516            spdx::Expression::parse("https://example.com/LICENSE").is_err()
1517        );
1518    }
1519
1520    #[test]
1521    fn staging_placeholder_strips_to_sysroot_relative_form() {
1522        assert_eq!(
1523            strip_staging_placeholder("${usrroot}/usr/share/fonts/foo.ttf"),
1524            "usr/share/fonts/foo.ttf"
1525        );
1526        assert_eq!(
1527            strip_staging_placeholder("${usrhome}/.local/share/fonts/foo.ttf"),
1528            ".local/share/fonts/foo.ttf"
1529        );
1530        assert_eq!(
1531            strip_staging_placeholder("${pkgstore}/bin/foo"),
1532            "pkgstore/bin/foo"
1533        );
1534        assert_eq!(
1535            strip_staging_placeholder("${applications}/MyApp.app"),
1536            "MyApp.app"
1537        );
1538        assert_eq!(
1539            strip_staging_placeholder("/usr/share/fonts/foo.ttf"),
1540            "/usr/share/fonts/foo.ttf"
1541        );
1542    }
1543
1544    #[test]
1545    fn staging_root_expansion_uses_canonical_locations() {
1546        let expanded =
1547            expand_staging_root_placeholders("${usrroot}/usr/share/fonts");
1548        assert!(
1549            expanded.ends_with("/usr/share/fonts"),
1550            "unexpected expansion: {expanded}"
1551        );
1552        // Store placeholder needs a version dir, so it stays untouched.
1553        assert_eq!(
1554            expand_staging_root_placeholders("${pkgstore}/bin/foo"),
1555            "${pkgstore}/bin/foo"
1556        );
1557    }
1558}