Skip to main content

zoi_cli/
utils.rs

1//! Utility functions for the Zoi CLI.
2
3use std::fmt::Display;
4use std::fs;
5use std::io::{Write, stdin, stdout};
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use anyhow::anyhow;
10use colored::Colorize;
11use crossterm::tty::IsTty;
12
13use crate::pkg::types::Scope;
14
15/// Prints information with a key and value.
16pub fn print_info<T: Display>(key: &str, value: T) {
17    println!("{key}: {value}");
18}
19
20/// Formats a version summary with branch, status, and number.
21pub fn format_version_summary(
22    branch: &str,
23    status: &str,
24    number: &str
25) -> String {
26    let branch_short = if branch == "Production" {
27        "Prod."
28    } else if branch == "Development" {
29        "Dev."
30    } else if branch == "Public" {
31        "Pub."
32    } else if branch == "Special" {
33        "Spec."
34    } else {
35        branch
36    };
37    format!(
38        "{} {} {}",
39        branch_short.blue().bold().italic(),
40        status,
41        number,
42    )
43}
44
45/// Formats a full version string including the commit hash.
46pub fn format_version_full(
47    branch: &str,
48    status: &str,
49    number: &str,
50    commit: &str
51) -> String {
52    format!(
53        "{} {}",
54        format_version_summary(branch, status, number),
55        commit.green()
56    )
57}
58
59/// Prints information aligned with a fixed width for the key.
60pub fn print_aligned_info(key: &str, value: &str) {
61    let key_with_colon = format!("{key}:");
62    println!("{:<18}{}", key_with_colon.cyan(), value);
63}
64
65/// Prints a warning if the package is from a non-standard repository.
66pub fn print_repo_warning(repo_name: &str) {
67    if crate::pkg::utils::is_mini_mode() {
68        if let Ok(index) = crate::pkg::mini_resolve::fetch_registry_index()
69            && let Some(pkg_info) =
70                index.packages.values().find(|p| p.repo == repo_name)
71        {
72            let warning_message = match pkg_info.repo_type.as_str() {
73                "unofficial" => Some(
74                    "This package is from an unofficial repository and is not \
75                     trusted."
76                ),
77                "community" => Some(
78                    "This package is from a community repository. Use with \
79                     caution."
80                ),
81                "test" => Some(
82                    "This package is from a testing repository and may not \
83                     function correctly."
84                ),
85                "archive" => Some(
86                    "This package is from an archive repository and is no \
87                     longer maintained."
88                ),
89                _ => None
90            };
91
92            if let Some(message) = warning_message {
93                println!("\n{}: {}", "NOTE".yellow().bold(), message.yellow());
94            }
95        }
96        return;
97    }
98
99    if let Ok(db_path) = crate::pkg::resolve::get_db_root()
100        && let Ok(repo_config) = crate::pkg::config::read_repo_config(&db_path)
101    {
102        let major_repo = repo_name.split('/').next().unwrap_or_default();
103        if let Some(repo_entry) =
104            repo_config.repos.iter().find(|r| r.name == major_repo)
105        {
106            let warning_message = match repo_entry.repo_type.as_str() {
107                "unofficial" => Some(
108                    "This package is from an unofficial repository and is not \
109                     trusted."
110                ),
111                "community" => Some(
112                    "This package is from a community repository. Use with \
113                     caution."
114                ),
115                "test" => Some(
116                    "This package is from a testing repository and may not \
117                     function correctly."
118                ),
119                "archive" => Some(
120                    "This package is from an archive repository and is no \
121                     longer maintained."
122                ),
123                _ => None
124            };
125
126            if let Some(message) = warning_message {
127                println!("\n{}: {}", "NOTE".yellow().bold(), message.yellow());
128            }
129        }
130    }
131}
132
133/// Gets all packages for shell completion.
134pub fn get_all_packages_for_completion() -> Vec<PackageCompletion> {
135    let mut completions = Vec::new();
136    let Ok(config) = crate::pkg::config::read_config() else {
137        return completions;
138    };
139
140    let mut registries = Vec::new();
141    if let Some(default) = &config.default_registry {
142        registries.push(default.handle.clone());
143    }
144    for reg in &config.added_registries {
145        registries.push(reg.handle.clone());
146    }
147
148    let default_handle = config.default_registry.as_ref().map(|r| &r.handle);
149
150    for handle in registries {
151        if handle.is_empty() {
152            continue;
153        }
154        if let Ok(entries) =
155            crate::pkg::db::get_packages_for_completion(&handle)
156        {
157            let is_default = default_handle == Some(&handle);
158            for entry in entries {
159                let base_name = if is_default {
160                    format!("@{}/{}", entry.repo, entry.name)
161                } else {
162                    format!("#{}@{}/{}", handle, entry.repo, entry.name)
163                };
164
165                let display = if let Some(sub) = entry.sub_package {
166                    format!("{base_name}:{sub}")
167                } else {
168                    base_name
169                };
170
171                completions.push(PackageCompletion {
172                    display,
173                    repo: entry.repo,
174                    description: entry.description
175                });
176            }
177        }
178    }
179
180    completions.sort_by(|a, b| a.display.cmp(&b.display));
181    completions
182}
183
184/// Represents a package completion entry.
185pub struct PackageCompletion {
186    /// The display name for the completion.
187    pub display: String,
188    /// The repository name.
189    pub repo: String,
190    /// The package description.
191    pub description: String
192}
193
194/// Creates a symlink to a file, replacing any existing file or symlink.
195///
196/// # Errors
197///
198/// Returns an error if:
199/// - The existing file or symlink cannot be removed.
200/// - The symlink, hard link, or file copy fails.
201pub fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
202    if link.exists() || link.is_symlink() {
203        fs::remove_file(link)?;
204    }
205
206    #[cfg(unix)]
207    {
208        std::os::unix::fs::symlink(target, link)
209    }
210    #[cfg(windows)]
211    {
212        if std::os::windows::fs::symlink_file(target, link).is_err() {
213            if fs::hard_link(target, link).is_err() {
214                fs::copy(target, link)?;
215            }
216        }
217        Ok(())
218    }
219}
220
221/// Checks if the current process has administrative or root privileges.
222pub fn is_admin() -> bool {
223    #[cfg(windows)]
224    {
225        use std::{mem, ptr};
226
227        use winapi::um::handleapi::CloseHandle;
228        use winapi::um::processthreadsapi::{
229            GetCurrentProcess, OpenProcessToken
230        };
231        use winapi::um::securitybaseapi::CheckTokenMembership;
232        use winapi::um::winnt::{PSID, TOKEN_QUERY};
233
234        let mut token = ptr::null_mut();
235        let process = unsafe { GetCurrentProcess() };
236        if unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) } == 0 {
237            return false;
238        }
239
240        let mut sid: [u8; 8] = [0; 8];
241        let mut sid_size = mem::size_of_val(&sid) as u32;
242        if unsafe {
243            winapi::um::securitybaseapi::CreateWellKnownSid(
244                winapi::um::winnt::WinBuiltinAdministratorsSid,
245                ptr::null_mut(),
246                sid.as_mut_ptr() as PSID,
247                &mut sid_size
248            )
249        } == 0
250        {
251            unsafe { CloseHandle(token) };
252            return false;
253        }
254
255        let mut is_member = 0;
256        let result = unsafe {
257            CheckTokenMembership(
258                token,
259                sid.as_mut_ptr() as PSID,
260                &mut is_member
261            )
262        };
263        unsafe { CloseHandle(token) };
264
265        result != 0 && is_member != 0
266    }
267    #[cfg(unix)]
268    {
269        nix::unistd::getuid().is_root()
270    }
271}
272
273/// Checks the license of a package and prints warnings if it's not OSI-approved
274/// or has issues.
275pub fn check_license(license: &str) {
276    if license.is_empty() {
277        println!(
278            "{} Package does not have a license specified.",
279            "Warning:".yellow()
280        );
281        return;
282    }
283
284    if license.eq_ignore_ascii_case("None") {
285        println!(
286            "{} Package does not provide a license.",
287            "Warning:".yellow()
288        );
289        return;
290    }
291
292    if license.eq_ignore_ascii_case("Proprietary") {
293        println!(
294            "{} Package is using a proprietary license.",
295            "Warning:".red()
296        );
297        return;
298    }
299
300    if license.eq_ignore_ascii_case("Unknown") {
301        println!("{} Package license is unknown.", "Warning:".red());
302        return;
303    }
304
305    match spdx::Expression::parse(license) {
306        Ok(expr) => {
307            if !expr.evaluate(|req| match req.license {
308                spdx::LicenseItem::Spdx { id, .. } => id.is_osi_approved(),
309                spdx::LicenseItem::Other { .. } => false
310            }) {
311                println!(
312                    "{} License expression '{}' does not evaluate to an OSI \
313                     approved license.",
314                    "Warning:".yellow(),
315                    license.yellow().bold()
316                );
317            }
318        }
319        Err(_) => {
320            println!(
321                "{} Could not parse license expression '{}'. It may not be a \
322                 valid SPDX identifier.",
323                "Warning:".yellow(),
324                license.yellow().bold()
325            );
326        }
327    }
328}
329
330/// Asks the user for confirmation with a prompt.
331pub fn ask_for_confirmation(prompt: &str, yes: bool) -> bool {
332    if yes {
333        return true;
334    }
335
336    if std::env::var("ZOI_TEST").is_ok() || !stdin().is_tty() {
337        return false;
338    }
339
340    print!("{} [y/N]: ", prompt.yellow());
341    let _ = stdout().flush();
342    let mut input = String::new();
343    if stdin().read_line(&mut input).is_err() {
344        return false;
345    }
346    input.trim().eq_ignore_ascii_case("y")
347}
348
349/// Sets up the PATH environment variable for the given scope.
350///
351/// # Errors
352///
353/// Returns an error if:
354/// - The home directory cannot be found for the user scope.
355/// - The directory for binaries cannot be created.
356/// - The shell configuration file cannot be read, created, or written to.
357/// - On Windows, the registry key for environment variables cannot be opened or
358///   modified.
359/// - Administrator privileges are missing for system scope on Windows.
360pub fn setup_path(scope: Scope) -> anyhow::Result<()> {
361    if scope == Scope::Project {
362        return Ok(());
363    }
364
365    let zoi_bin_dir = match scope {
366        Scope::User => {
367            let home = crate::pkg::utils::get_user_home()
368                .ok_or_else(|| anyhow!("Could not find home directory."))?;
369            crate::pkg::sysroot::apply_sysroot(
370                home.join(".zoi").join("pkgs").join("bin")
371            )
372        }
373        Scope::System => {
374            if cfg!(target_os = "windows") {
375                crate::pkg::sysroot::apply_sysroot(PathBuf::from(
376                    "C:\\ProgramData\\zoi\\pkgs\\bin"
377                ))
378            } else {
379                crate::pkg::sysroot::apply_sysroot(PathBuf::from(
380                    "/usr/local/bin"
381                ))
382            }
383        }
384        Scope::Project => return Ok(())
385    };
386
387    if !zoi_bin_dir.exists() {
388        fs::create_dir_all(&zoi_bin_dir)?;
389    }
390
391    if scope == Scope::System && cfg!(unix) {
392        println!(
393            "{}",
394            "System-wide installation complete. Binaries are in the system \
395             PATH."
396                .green()
397        );
398        return Ok(());
399    }
400
401    #[cfg(unix)]
402    {
403        use std::fs::{File, OpenOptions};
404        let home = crate::pkg::utils::get_user_home()
405            .ok_or_else(|| anyhow!("Could not find home directory."))?;
406        let zoi_bin_str = "$HOME/.zoi/pkgs/bin";
407
408        let shell_name = std::env::var("SHELL").unwrap_or_default();
409        let (profile_file_path, cmd_to_write) = if shell_name.contains("bash") {
410            let path = if cfg!(target_os = "macos") {
411                home.join(".bash_profile")
412            } else {
413                home.join(".bashrc")
414            };
415            let cmd = format!(
416                "\n# Added by Zoi\nexport PATH=\"{}:{}\"\n",
417                zoi_bin_str, "$PATH"
418            );
419            (path, cmd)
420        } else if shell_name.contains("zsh") {
421            let path = home.join(".zshrc");
422            let cmd = format!(
423                "\n# Added by Zoi\nexport PATH=\"{}:{}\"\n",
424                zoi_bin_str, "$PATH"
425            );
426            (path, cmd)
427        } else if shell_name.contains("fish") {
428            let path = home.join(".config/fish/config.fish");
429            let cmd =
430                format!("\n# Added by Zoi\nfish_add_path \"{zoi_bin_str}\"\n");
431            (path, cmd)
432        } else if shell_name.contains("elvish") {
433            let path = home.join(".config/elvish/rc.elv");
434            let cmd = "
435# Added by Zoi
436set paths = [ ~/.zoi/pkgs/bin $paths... ]
437"
438            .to_string();
439            (path, cmd)
440        } else if shell_name.contains("csh") || shell_name.contains("tcsh") {
441            let path = home.join(".cshrc");
442            let cmd = format!(
443                "\n# Added by Zoi\nsetenv PATH=\"{}:{}\"\n",
444                zoi_bin_str, "$PATH"
445            );
446            (path, cmd)
447        } else {
448            let path = home.join(".profile");
449            let cmd = format!(
450                "\n# Added by Zoi\nexport PATH=\"{}:{}\"\n",
451                zoi_bin_str, "$PATH"
452            );
453            (path, cmd)
454        };
455
456        if !profile_file_path.exists() {
457            if let Some(parent) = profile_file_path.parent() {
458                fs::create_dir_all(parent)?;
459            }
460            File::create(&profile_file_path)?;
461        }
462
463        let content = fs::read_to_string(&profile_file_path)?;
464        if content.contains(zoi_bin_str) {
465            println!("Zoi bin directory is already in your shell's config.");
466            return Ok(());
467        }
468
469        let mut file =
470            OpenOptions::new().append(true).open(&profile_file_path)?;
471
472        file.write_all(cmd_to_write.as_bytes())?;
473
474        println!(
475            "{} Zoi bin directory has been added to your PATH in '{}'.",
476            "Success:".green(),
477            profile_file_path.display()
478        );
479        println!(
480            "Please restart your shell or run `source {}` for the changes to \
481             take effect.",
482            profile_file_path.display()
483        );
484    }
485
486    #[cfg(windows)]
487    {
488        use winreg::RegKey;
489        use winreg::enums::*;
490
491        let zoi_bin_path_str = zoi_bin_dir
492            .to_str()
493            .ok_or_else(|| anyhow!("Invalid path string"))?;
494
495        let (root, subkey, scope_name) = if scope == Scope::System {
496            if !is_admin() {
497                return Err(anyhow!(
498                    "Administrator privileges required to modify system PATH."
499                ));
500            }
501            (
502                HKEY_LOCAL_MACHINE,
503                "System\\CurrentControlSet\\Control\\Session \
504                 Manager\\Environment",
505                "system"
506            )
507        } else {
508            (HKEY_CURRENT_USER, "Environment", "user")
509        };
510
511        let key = RegKey::predef(root);
512        let env = key.open_subkey_with_flags(subkey, KEY_READ | KEY_WRITE)?;
513        let current_path: String = env.get_value("Path")?;
514
515        if current_path
516            .split(';')
517            .any(|p| p.eq_ignore_ascii_case(zoi_bin_path_str))
518        {
519            println!("Zoi bin directory is already in your PATH.");
520            return Ok(());
521        }
522
523        let new_path = if current_path.is_empty() {
524            zoi_bin_path_str.to_string()
525        } else {
526            format!("{};{}", current_path, zoi_bin_path_str)
527        };
528        env.set_value("Path", &new_path)?;
529
530        println!(
531            "{} Zoi bin directory has been added to your {} PATH environment \
532             variable.",
533            "Success:".green(),
534            scope_name
535        );
536        println!(
537            "Please restart your shell or log out and log back in for the \
538             changes to take effect."
539        );
540    }
541
542    Ok(())
543}
544
545/// Checks if the Zoi bin directory is in the current PATH and prints a warning
546/// if not.
547pub fn check_path() {
548    if let Some(home) = crate::pkg::utils::get_user_home() {
549        let zoi_bin_dir =
550            crate::pkg::sysroot::apply_sysroot(home.join(".zoi/pkgs/bin"));
551        if !zoi_bin_dir.exists() {
552            return;
553        }
554    } else {
555        return;
556    }
557
558    let command_output = if cfg!(target_os = "windows") {
559        Command::new("pwsh")
560            .arg("-Command")
561            .arg("echo $env:Path")
562            .output()
563    } else {
564        Command::new("bash").arg("-c").arg("echo $PATH").output()
565    };
566
567    let is_in_path = match command_output {
568        Ok(output) => {
569            if output.status.success() {
570                let path_var = String::from_utf8_lossy(&output.stdout);
571                path_var.contains(".zoi/pkgs/bin")
572            } else {
573                false
574            }
575        }
576        Err(_) => false
577    };
578
579    if !is_in_path {
580        eprintln!(
581            "Please run 'zoi shell <shell>' or add it to your PATH manually \
582             for commands to be available."
583        );
584    }
585}