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    match spdx::Expression::parse(license) {
623        Ok(expr) => {
624            if !expr.evaluate(|req| match req.license {
625                spdx::LicenseItem::Spdx { id, .. } => id.is_osi_approved(),
626                spdx::LicenseItem::Other { .. } => false,
627            }) {
628                println!(
629                    "{}{}{}",
630                    "Warning: License '".yellow(),
631                    license.yellow().bold(),
632                    "' is not an OSI approved license.".yellow()
633                );
634            }
635        }
636        Err(_) => {
637            println!(
638                "{}{}{}",
639                "Warning: Could not parse license expression '".yellow(),
640                license.yellow().bold(),
641                "' It may not be a valid SPDX identifier.".yellow()
642            );
643        }
644    }
645}
646
647#[derive(serde::Deserialize)]
648struct PackageForCompletion {
649    description: Option<String>,
650}
651
652pub struct PackageCompletion {
653    pub display: String,
654    pub repo: String,
655    pub description: String,
656}
657
658pub fn get_all_packages_for_completion() -> Vec<PackageCompletion> {
659    let db_root = if let Ok(path) = crate::pkg::resolve::get_db_root() {
660        path
661    } else {
662        return Vec::new();
663    };
664
665    let active_repos = if let Ok(config) = crate::pkg::config::read_config() {
666        config.repos
667    } else {
668        return Vec::new();
669    };
670
671    if !db_root.exists() {
672        return Vec::new();
673    }
674
675    let mut packages = Vec::new();
676    for repo_name in &active_repos {
677        let repo_path = db_root.join(repo_name);
678        if !repo_path.is_dir() {
679            continue;
680        }
681        for entry in WalkDir::new(&repo_path)
682            .into_iter()
683            .filter_map(|e| e.ok())
684            .filter(|e| e.file_type().is_dir())
685        {
686            let pkg_name = entry.file_name().to_string_lossy();
687            let pkg_file_path = entry.path().join(format!("{}.pkg.lua", pkg_name));
688
689            if pkg_file_path.is_file() {
690                let pkg_info: anyhow::Result<PackageForCompletion> = (|| -> anyhow::Result<_> {
691                    let pkg = crate::pkg::lua::parser::parse_lua_package(
692                        pkg_file_path.to_str().unwrap(),
693                        None,
694                    )?;
695                    Ok(PackageForCompletion {
696                        description: Some(pkg.description),
697                    })
698                })();
699
700                let description = match pkg_info {
701                    Ok(pi) => pi.description.unwrap_or_default(),
702                    Err(_) => String::new(),
703                };
704
705                let relative_path = entry.path().strip_prefix(&db_root).unwrap();
706                let full_pkg_id =
707                    format!("@{}", relative_path.to_string_lossy().replace('\\', "/"));
708
709                packages.push(PackageCompletion {
710                    display: full_pkg_id,
711                    repo: repo_name.clone(),
712                    description,
713                });
714            }
715        }
716    }
717    packages.sort_by(|a, b| a.display.cmp(&b.display));
718    packages
719}
720
721pub fn get_current_shell() -> Option<Shell> {
722    if cfg!(windows) {
723        return Some(Shell::PowerShell);
724    }
725
726    if let Ok(shell_path) = std::env::var("SHELL") {
727        let shell_name = Path::new(&shell_path).file_name()?.to_str()?;
728        match shell_name {
729            "bash" => Some(Shell::Bash),
730            "zsh" => Some(Shell::Zsh),
731            "fish" => Some(Shell::Fish),
732            "elvish" => Some(Shell::Elvish),
733            "pwsh" => Some(Shell::PowerShell),
734            _ => None,
735        }
736    } else {
737        None
738    }
739}