zoi/
utils.rs

1use crate::pkg::resolve::SourceType;
2use anyhow::anyhow;
3use colored::*;
4use std::fmt::Display;
5use std::fs;
6use std::io::{Write, stdin, stdout};
7use std::process::Command;
8use std::time::Duration;
9use walkdir::WalkDir;
10
11use crate::pkg::types::Scope;
12use clap_complete::Shell;
13use std::path::{Path, PathBuf};
14
15pub fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
16    fs::create_dir_all(dst)?;
17    for entry in fs::read_dir(src)? {
18        let entry = entry?;
19        let ty = entry.file_type()?;
20        if ty.is_dir() {
21            copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
22        } else {
23            fs::copy(entry.path(), dst.join(entry.file_name()))?;
24        }
25    }
26    Ok(())
27}
28
29pub fn is_admin() -> bool {
30    #[cfg(windows)]
31    {
32        use std::mem;
33        use std::ptr;
34        use winapi::um::handleapi::CloseHandle;
35        use winapi::um::processthreadsapi::GetCurrentProcess;
36        use winapi::um::processthreadsapi::OpenProcessToken;
37        use winapi::um::securitybaseapi::CheckTokenMembership;
38        use winapi::um::winnt::{PSID, TOKEN_QUERY};
39
40        let mut token = ptr::null_mut();
41        let process = unsafe { GetCurrentProcess() };
42        if unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) } == 0 {
43            return false;
44        }
45
46        let mut sid: [u8; 8] = [0; 8];
47        let mut sid_size = mem::size_of_val(&sid) as u32;
48        if unsafe {
49            winapi::um::securitybaseapi::CreateWellKnownSid(
50                winapi::um::winnt::WinBuiltinAdministratorsSid,
51                ptr::null_mut(),
52                sid.as_mut_ptr() as PSID,
53                &mut sid_size,
54            )
55        } == 0
56        {
57            unsafe { CloseHandle(token) };
58            return false;
59        }
60
61        let mut is_member = 0;
62        let result =
63            unsafe { CheckTokenMembership(token, sid.as_mut_ptr() as PSID, &mut is_member) };
64        unsafe { CloseHandle(token) };
65
66        result != 0 && is_member != 0
67    }
68    #[cfg(unix)]
69    {
70        nix::unistd::getuid().is_root()
71    }
72}
73
74pub fn print_info<T: Display>(key: &str, value: T) {
75    println!("{}: {}", key, value);
76}
77
78pub fn format_version_summary(branch: &str, status: &str, number: &str) -> String {
79    let branch_short = if branch == "Production" {
80        "Prod."
81    } else if branch == "Development" {
82        "Dev."
83    } else if branch == "Public" {
84        "Pub."
85    } else if branch == "Special" {
86        "Spec."
87    } else {
88        branch
89    };
90    format!(
91        "{} {} {}",
92        branch_short.blue().bold().italic(),
93        status,
94        number,
95    )
96}
97
98pub fn format_version_full(branch: &str, status: &str, number: &str, commit: &str) -> String {
99    format!(
100        "{} {}",
101        format_version_summary(branch, status, number),
102        commit.green()
103    )
104}
105
106pub fn print_aligned_info(key: &str, value: &str) {
107    let key_with_colon = format!("{}:", key);
108    println!("{:<18}{}", key_with_colon.cyan(), value);
109}
110
111pub fn command_exists(command: &str) -> bool {
112    if cfg!(target_os = "windows") {
113        Command::new("where")
114            .arg(command)
115            .stdout(std::process::Stdio::null())
116            .stderr(std::process::Stdio::null())
117            .status()
118            .is_ok_and(|status| status.success())
119    } else {
120        Command::new("bash")
121            .arg("-c")
122            .arg(format!("command -v {}", command))
123            .stdout(std::process::Stdio::null())
124            .stderr(std::process::Stdio::null())
125            .status()
126            .is_ok_and(|status| status.success())
127    }
128}
129
130pub fn ask_for_confirmation(prompt: &str, yes: bool) -> bool {
131    if yes {
132        return true;
133    }
134    print!("{} [y/N]: ", prompt.yellow());
135    let _ = stdout().flush();
136    let mut input = String::new();
137    if stdin().read_line(&mut input).is_err() {
138        return false;
139    }
140    input.trim().eq_ignore_ascii_case("y")
141}
142
143use std::collections::HashMap;
144
145pub fn get_linux_distribution_info() -> Option<HashMap<String, String>> {
146    if let Ok(contents) = fs::read_to_string("/etc/os-release") {
147        let info: HashMap<String, String> = contents
148            .lines()
149            .filter_map(|line| {
150                let mut parts = line.splitn(2, '=');
151                let key = parts.next()?;
152                let value = parts.next()?.trim_matches('"').to_string();
153                if key.is_empty() {
154                    None
155                } else {
156                    Some((key.to_string(), value))
157                }
158            })
159            .collect();
160        if info.is_empty() { None } else { Some(info) }
161    } else {
162        None
163    }
164}
165
166pub fn get_linux_distro_family() -> Option<String> {
167    if let Some(info) = get_linux_distribution_info() {
168        if let Some(id_like) = info.get("ID_LIKE") {
169            let families: Vec<&str> = id_like.split_whitespace().collect();
170            if families.contains(&"debian") {
171                return Some("debian".to_string());
172            }
173            if families.contains(&"arch") {
174                return Some("arch".to_string());
175            }
176            if families.contains(&"fedora") {
177                return Some("fedora".to_string());
178            }
179            if families.contains(&"rhel") {
180                return Some("fedora".to_string());
181            }
182            if families.contains(&"suse") {
183                return Some("suse".to_string());
184            }
185            if families.contains(&"gentoo") {
186                return Some("gentoo".to_string());
187            }
188        }
189        if let Some(id) = info.get("ID") {
190            return match id.as_str() {
191                "debian" | "ubuntu" | "linuxmint" | "pop" | "kali" | "kubuntu" | "lubuntu"
192                | "xubuntu" | "zorin" | "elementary" => Some("debian".to_string()),
193                "arch" | "manjaro" | "cachyos" | "endeavouros" | "garuda" => {
194                    Some("arch".to_string())
195                }
196                "fedora" | "centos" | "rhel" | "rocky" | "almalinux" => Some("fedora".to_string()),
197                "opensuse" | "opensuse-tumbleweed" | "opensuse-leap" => Some("suse".to_string()),
198                "gentoo" => Some("gentoo".to_string()),
199                "alpine" => Some("alpine".to_string()),
200                "void" => Some("void".to_string()),
201                "solus" => Some("solus".to_string()),
202                "guix" => Some("guix".to_string()),
203                _ => None,
204            };
205        }
206    }
207    None
208}
209
210pub fn get_linux_distribution() -> Option<String> {
211    get_linux_distribution_info().and_then(|info| info.get("ID").cloned())
212}
213
214pub fn get_native_package_manager() -> Option<String> {
215    let os = std::env::consts::OS;
216    match os {
217        "linux" => get_linux_distro_family()
218            .map(|family| {
219                match family.as_str() {
220                    "debian" => "apt",
221                    "arch" => "pacman",
222                    "fedora" => "dnf",
223                    "suse" => "zypper",
224                    "gentoo" => "portage",
225                    "alpine" => "apk",
226                    "void" => "xbps-install",
227                    "solus" => "eopkg",
228                    "guix" => "guix",
229                    _ => "unknown",
230                }
231                .to_string()
232            })
233            .filter(|s| s != "unknown"),
234        "macos" => {
235            if command_exists("brew") {
236                Some("brew".to_string())
237            } else if command_exists("port") {
238                Some("macports".to_string())
239            } else {
240                None
241            }
242        }
243        "windows" => {
244            if command_exists("scoop") {
245                Some("scoop".to_string())
246            } else if command_exists("choco") {
247                Some("choco".to_string())
248            } else if command_exists("winget") {
249                Some("winget".to_string())
250            } else {
251                None
252            }
253        }
254        "freebsd" => Some("pkg".to_string()),
255        "openbsd" => Some("pkg_add".to_string()),
256        _ => None,
257    }
258}
259
260pub fn print_repo_warning(repo_name: &str) {
261    if let Ok(db_path) = crate::pkg::resolve::get_db_root()
262        && let Ok(repo_config) = crate::pkg::config::read_repo_config(&db_path)
263    {
264        let major_repo = repo_name.split('/').next().unwrap_or("");
265        if let Some(repo_entry) = repo_config.repos.iter().find(|r| r.name == major_repo) {
266            let warning_message = match repo_entry.repo_type.as_str() {
267                "unoffical" => {
268                    Some("This package is from an unofficial repository and is not trusted.")
269                }
270                "community" => {
271                    Some("This package is from a community repository. Use with caution.")
272                }
273                "test" => Some(
274                    "This package is from a testing repository and may not function correctly.",
275                ),
276                "archive" => {
277                    Some("This package is from an archive repository and is no longer maintained.")
278                }
279                _ => None,
280            };
281
282            if let Some(message) = warning_message {
283                println!("\n{}: {}", "NOTE".yellow().bold(), message.yellow());
284            }
285        }
286    }
287}
288
289pub fn confirm_untrusted_source(source_type: &SourceType, yes: bool) -> anyhow::Result<()> {
290    if source_type == &SourceType::OfficialRepo {
291        return Ok(());
292    }
293
294    let warning_message = match source_type {
295        SourceType::UntrustedRepo(repo) => {
296            format!(
297                "The package from repository '@{}' is not an official Zoi repository.",
298                repo
299            )
300        }
301        SourceType::LocalFile => "You are installing from a local file.".to_string(),
302        SourceType::Url => "You are installing from a remote URL.".to_string(),
303        _ => return Ok(()),
304    };
305
306    println!(
307        "\n{}: {}",
308        "SECURITY WARNING".yellow().bold(),
309        warning_message
310    );
311
312    if ask_for_confirmation(
313        "This source is not trusted. Are you sure you want to continue?",
314        yes,
315    ) {
316        Ok(())
317    } else {
318        Err(anyhow!("Operation aborted by user."))
319    }
320}
321
322pub fn is_platform_compatible(current_platform: &str, allowed_platforms: &[String]) -> bool {
323    let os = match std::env::consts::OS {
324        "darwin" => "macos",
325        other => other,
326    };
327    allowed_platforms
328        .iter()
329        .any(|p| p == "all" || p == os || p == current_platform)
330}
331
332pub fn setup_path(scope: Scope) -> anyhow::Result<()> {
333    if scope == Scope::Project {
334        return Ok(());
335    }
336
337    let zoi_bin_dir = match scope {
338        Scope::User => home::home_dir()
339            .ok_or_else(|| anyhow!("Could not find home directory."))?
340            .join(".zoi")
341            .join("pkgs")
342            .join("bin"),
343        Scope::System => {
344            if cfg!(target_os = "windows") {
345                PathBuf::from("C:\\ProgramData\\zoi\\pkgs\\bin")
346            } else {
347                PathBuf::from("/usr/local/bin")
348            }
349        }
350        Scope::Project => return Ok(()),
351    };
352
353    if !zoi_bin_dir.exists() {
354        fs::create_dir_all(&zoi_bin_dir)?;
355    }
356
357    if scope == Scope::System {
358        println!(
359            "{}",
360            "System-wide installation complete. Binaries are in the system PATH.".green()
361        );
362        return Ok(());
363    }
364
365    println!("{}", "Ensuring Zoi bin directory is in your PATH...".bold());
366
367    #[cfg(unix)]
368    {
369        use std::fs::{File, OpenOptions};
370        let home = home::home_dir().ok_or_else(|| anyhow!("Could not find home directory."))?;
371        let zoi_bin_str = "$HOME/.zoi/pkgs/bin";
372
373        let shell_name = std::env::var("SHELL").unwrap_or_default();
374        let (profile_file_path, cmd_to_write) = if shell_name.contains("bash") {
375            let path = if cfg!(target_os = "macos") {
376                home.join(".bash_profile")
377            } else {
378                home.join(".bashrc")
379            };
380            let cmd = format!(
381                "\n# Added by Zoi\nexport PATH=\"{}:{}\"\n",
382                zoi_bin_str, "$PATH"
383            );
384            (path, cmd)
385        } else if shell_name.contains("zsh") {
386            let path = home.join(".zshrc");
387            let cmd = format!(
388                "\n# Added by Zoi\nexport PATH=\"{}:{}\"\n",
389                zoi_bin_str, "$PATH"
390            );
391            (path, cmd)
392        } else if shell_name.contains("fish") {
393            let path = home.join(".config/fish/config.fish");
394            let cmd = format!("\n# Added by Zoi\nset -gx PATH \"{}\" $PATH\n", zoi_bin_str);
395
396            (path, cmd)
397        } else if shell_name.contains("elvish") {
398            let path = home.join(".config/elvish/rc.elv");
399            let cmd = "
400# Added by Zoi
401set paths = [ ~/.zoi/pkgs/bin $paths... ]
402"
403            .to_string();
404            (path, cmd)
405        } else if shell_name.contains("csh") || shell_name.contains("tcsh") {
406            let path = home.join(".cshrc");
407            let cmd = format!(
408                "\n# Added by Zoi\nsetenv PATH=\"{}:{}\"\n",
409                zoi_bin_str, "$PATH"
410            );
411            (path, cmd)
412        } else {
413            let path = home.join(".profile");
414            let cmd = format!(
415                "\n# Added by Zoi\nexport PATH=\"{}:{}\"\n",
416                zoi_bin_str, "$PATH"
417            );
418            (path, cmd)
419        };
420
421        if !profile_file_path.exists() {
422            if let Some(parent) = profile_file_path.parent() {
423                fs::create_dir_all(parent)?;
424            }
425            File::create(&profile_file_path)?;
426        }
427
428        let content = fs::read_to_string(&profile_file_path)?;
429        if content.contains(zoi_bin_str) {
430            println!("Zoi bin directory is already in your shell's config.");
431            return Ok(());
432        }
433
434        let mut file = OpenOptions::new().append(true).open(&profile_file_path)?;
435
436        file.write_all(cmd_to_write.as_bytes())?;
437
438        println!(
439            "{} Zoi bin directory has been added to your PATH in '{}'.",
440            "Success:".green(),
441            profile_file_path.display()
442        );
443        println!(
444            "Please restart your shell or run `source {}` for the changes to take effect.",
445            profile_file_path.display()
446        );
447    }
448
449    #[cfg(windows)]
450    {
451        use winreg::RegKey;
452        use winreg::enums::*;
453
454        let zoi_bin_path_str = zoi_bin_dir
455            .to_str()
456            .ok_or_else(|| anyhow!("Invalid path string"))?;
457
458        let hkcu = RegKey::predef(HKEY_CURRENT_USER);
459        let env = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)?;
460        let current_path: String = env.get_value("Path")?;
461
462        if current_path
463            .split(';')
464            .any(|p| p.eq_ignore_ascii_case(zoi_bin_path_str))
465        {
466            println!("Zoi bin directory is already in your PATH.");
467            return Ok(());
468        }
469
470        let new_path = if current_path.is_empty() {
471            zoi_bin_path_str.to_string()
472        } else {
473            format!("{};{}", current_path, zoi_bin_path_str)
474        };
475        env.set_value("Path", &new_path)?;
476
477        println!(
478            "{} Zoi bin directory has been added to your user PATH environment variable.",
479            "Success:".green()
480        );
481        println!(
482            "Please restart your shell or log out and log back in for the changes to take effect."
483        );
484    }
485
486    Ok(())
487}
488
489pub fn check_path() {
490    if let Some(home) = home::home_dir() {
491        let zoi_bin_dir = home.join(".zoi/pkgs/bin");
492        if !zoi_bin_dir.exists() {
493            return;
494        }
495    } else {
496        return;
497    }
498
499    let command_output = if cfg!(target_os = "windows") {
500        Command::new("pwsh")
501            .arg("-Command")
502            .arg("echo $env:Path")
503            .output()
504    } else {
505        Command::new("bash").arg("-c").arg("echo $PATH").output()
506    };
507
508    let is_in_path = match command_output {
509        Ok(output) => {
510            if output.status.success() {
511                let path_var = String::from_utf8_lossy(&output.stdout);
512                path_var.contains(".zoi/pkgs/bin")
513            } else {
514                false
515            }
516        }
517        Err(_) => false,
518    };
519
520    if !is_in_path {
521        eprintln!(
522            "Please run 'zoi setup --scope user' or add it to your PATH manually for commands to be available."
523        );
524    }
525}
526
527pub fn get_platform() -> anyhow::Result<String> {
528    let os = match std::env::consts::OS {
529        "linux" => "linux",
530        "macos" | "darwin" => "macos",
531        "windows" => "windows",
532        "freebsd" => "freebsd",
533        "openbsd" => "openbsd",
534        unsupported_os => return Err(anyhow!("Unsupported operating system: {}", unsupported_os)),
535    };
536
537    let arch = match std::env::consts::ARCH {
538        "x86_64" => "amd64",
539        "aarch64" => "arm64",
540        unsupported_arch => return Err(anyhow!("Unsupported architecture: {}", unsupported_arch)),
541    };
542
543    Ok(format!("{}-{}", os, arch))
544}
545
546pub fn get_all_available_package_managers() -> Vec<String> {
547    let mut managers = Vec::new();
548    let all_possible_managers = [
549        "apt",
550        "apt-get",
551        "pacman",
552        "yay",
553        "paru",
554        "pikaur",
555        "trizen",
556        "dnf",
557        "yum",
558        "zypper",
559        "portage",
560        "apk",
561        "snap",
562        "flatpak",
563        "nix",
564        "brew",
565        "port",
566        "scoop",
567        "choco",
568        "winget",
569        "pkg",
570        "pkg_add",
571        "xbps-install",
572        "eopkg",
573        "guix",
574        "mas",
575    ];
576
577    for manager in &all_possible_managers {
578        if command_exists(manager) {
579            managers.push(manager.to_string());
580        }
581    }
582    managers.sort();
583    managers.dedup();
584    managers
585}
586
587pub fn build_blocking_http_client(timeout_secs: u64) -> anyhow::Result<reqwest::blocking::Client> {
588    let client = reqwest::blocking::Client::builder()
589        .timeout(Duration::from_secs(timeout_secs))
590        .build()?;
591    Ok(client)
592}
593
594pub fn retry_backoff_sleep(attempt: u32) {
595    let base_ms = 500u64.saturating_mul(1u64 << (attempt.saturating_sub(1)));
596    let jitter = (std::time::SystemTime::now()
597        .duration_since(std::time::UNIX_EPOCH)
598        .unwrap_or(Duration::from_secs(0))
599        .subsec_millis()
600        % 200) as u64;
601    let sleep_ms = (base_ms + jitter).min(8000);
602    std::thread::sleep(Duration::from_millis(sleep_ms));
603}
604
605pub fn check_license(license: &str) {
606    if license.is_empty() {
607        println!(
608            "{}",
609            "Warning: Package does not have a license specified.".yellow()
610        );
611        return;
612    }
613
614    if license.eq_ignore_ascii_case("Proprietary") {
615        println!(
616            "{}",
617            "Warning: Package is using a proprietary license.".red()
618        );
619        return;
620    }
621
622    if license.eq_ignore_ascii_case("Unkown") {
623        println!("{}", "Warning: Package license is unkown.".red());
624        return;
625    }
626
627    match spdx::Expression::parse(license) {
628        Ok(expr) => {
629            if !expr.evaluate(|req| match req.license {
630                spdx::LicenseItem::Spdx { id, .. } => id.is_osi_approved(),
631                spdx::LicenseItem::Other { .. } => false,
632            }) {
633                println!(
634                    "{}{}{}",
635                    "Warning: License '".yellow(),
636                    license.yellow().bold(),
637                    "' is not an OSI approved license.".yellow()
638                );
639            }
640        }
641        Err(_) => {
642            println!(
643                "{}{}{}",
644                "Warning: Could not parse license expression '".yellow(),
645                license.yellow().bold(),
646                "' It may not be a valid SPDX identifier.".yellow()
647            );
648        }
649    }
650}
651
652#[derive(serde::Deserialize)]
653struct PackageForCompletion {
654    description: Option<String>,
655}
656
657pub struct PackageCompletion {
658    pub display: String,
659    pub repo: String,
660    pub description: String,
661}
662
663pub fn get_all_packages_for_completion() -> Vec<PackageCompletion> {
664    let db_root = if let Ok(path) = crate::pkg::resolve::get_db_root() {
665        path
666    } else {
667        return Vec::new();
668    };
669
670    let active_repos = if let Ok(config) = crate::pkg::config::read_config() {
671        config.repos
672    } else {
673        return Vec::new();
674    };
675
676    if !db_root.exists() {
677        return Vec::new();
678    }
679
680    let mut packages = Vec::new();
681    for repo_name in &active_repos {
682        let repo_path = db_root.join(repo_name);
683        if !repo_path.is_dir() {
684            continue;
685        }
686        for entry in WalkDir::new(&repo_path)
687            .into_iter()
688            .filter_map(|e| e.ok())
689            .filter(|e| e.file_type().is_dir())
690        {
691            let pkg_name = entry.file_name().to_string_lossy();
692            let pkg_file_path = entry.path().join(format!("{}.pkg.lua", pkg_name));
693
694            if pkg_file_path.is_file() {
695                let pkg_info: anyhow::Result<PackageForCompletion> = (|| -> anyhow::Result<_> {
696                    let pkg = crate::pkg::lua::parser::parse_lua_package(
697                        pkg_file_path.to_str().unwrap(),
698                        None,
699                    )?;
700                    Ok(PackageForCompletion {
701                        description: Some(pkg.description),
702                    })
703                })();
704
705                let description = match pkg_info {
706                    Ok(pi) => pi.description.unwrap_or_default(),
707                    Err(_) => String::new(),
708                };
709
710                let relative_path = entry.path().strip_prefix(&db_root).unwrap();
711                let full_pkg_id =
712                    format!("@{}", relative_path.to_string_lossy().replace('\\', "/"));
713
714                packages.push(PackageCompletion {
715                    display: full_pkg_id,
716                    repo: repo_name.clone(),
717                    description,
718                });
719            }
720        }
721    }
722    packages.sort_by(|a, b| a.display.cmp(&b.display));
723    packages
724}
725
726pub fn get_current_shell() -> Option<Shell> {
727    if cfg!(windows) {
728        return Some(Shell::PowerShell);
729    }
730
731    if let Ok(shell_path) = std::env::var("SHELL") {
732        let shell_name = Path::new(&shell_path).file_name()?.to_str()?;
733        match shell_name {
734            "bash" => Some(Shell::Bash),
735            "zsh" => Some(Shell::Zsh),
736            "fish" => Some(Shell::Fish),
737            "elvish" => Some(Shell::Elvish),
738            "pwsh" => Some(Shell::PowerShell),
739            _ => None,
740        }
741    } else {
742        None
743    }
744}