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!("{prompt} [y/N]: ");
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/// Escapes a string for use inside an Elvish double-quoted string literal.
350///
351/// Backslashes are escaped first so that escaping the double quotes
352/// afterwards cannot produce double-escaped sequences.
353fn elvish_quote(value: &str) -> String {
354    value.replace('\\', "\\\\").replace('"', "\\\"")
355}
356
357/// Sets up the PATH environment variable for the given scope.
358///
359/// # Errors
360///
361/// Returns an error if:
362/// - The home directory cannot be found for the user scope.
363/// - The directory for binaries cannot be created.
364/// - The shell configuration file cannot be read, created, or written to.
365/// - On Windows, the registry key for environment variables cannot be opened or
366///   modified.
367/// - Administrator privileges are missing for system scope on Windows.
368pub fn setup_path(scope: Scope) -> anyhow::Result<()> {
369    if scope == Scope::Project {
370        return Ok(());
371    }
372
373    let zoi_bin_dir = match scope {
374        Scope::User => crate::pkg::utils::get_user_bin_dir()?,
375        Scope::System => {
376            if cfg!(target_os = "windows") {
377                crate::pkg::sysroot::apply_sysroot(PathBuf::from(
378                    "C:\\ProgramData\\zoi\\pkgs\\bin"
379                ))
380            } else {
381                crate::pkg::sysroot::apply_sysroot(PathBuf::from(
382                    "/usr/local/bin"
383                ))
384            }
385        }
386        Scope::Project => return Ok(())
387    };
388
389    if !zoi_bin_dir.exists() {
390        fs::create_dir_all(&zoi_bin_dir)?;
391    }
392
393    if scope == Scope::System && cfg!(unix) {
394        println!(
395            "{}",
396            "System-wide installation complete. Binaries are in the system \
397             PATH."
398                .green()
399        );
400        return Ok(());
401    }
402
403    #[cfg(unix)]
404    {
405        use std::fs::{File, OpenOptions};
406        let home = crate::pkg::utils::get_user_home()
407            .ok_or_else(|| anyhow!("Could not find home directory."))?;
408        let zoi_bin_str = zoi_bin_dir.to_string_lossy();
409
410        let shell_name = std::env::var("SHELL").unwrap_or_default();
411        let (profile_file_path, cmd_to_write) = if shell_name.contains("bash") {
412            let path = if cfg!(target_os = "macos") {
413                home.join(".bash_profile")
414            } else {
415                home.join(".bashrc")
416            };
417            let cmd = format!(
418                "\n# Added by Zoi\nexport PATH=\"{}:{}\"\n",
419                zoi_bin_str, "$PATH"
420            );
421            (path, cmd)
422        } else if shell_name.contains("zsh") {
423            let path = home.join(".zshrc");
424            let cmd = format!(
425                "\n# Added by Zoi\nexport PATH=\"{}:{}\"\n",
426                zoi_bin_str, "$PATH"
427            );
428            (path, cmd)
429        } else if shell_name.contains("fish") {
430            let path = home.join(".config/fish/config.fish");
431            let cmd =
432                format!("\n# Added by Zoi\nfish_add_path \"{zoi_bin_str}\"\n");
433            (path, cmd)
434        } else if shell_name.contains("elvish") {
435            let path = home.join(".config/elvish/rc.elv");
436            // Escape the path so it parses as one literal string value even
437            // when it contains whitespace, quotes, or backslashes.
438            let cmd = format!(
439                "\n# Added by Zoi\nset paths = [ \"{}\" $paths... ]\n",
440                elvish_quote(&zoi_bin_str)
441            );
442            (path, cmd)
443        } else if shell_name.contains("csh") || shell_name.contains("tcsh") {
444            let path = home.join(".cshrc");
445            let cmd = format!(
446                "\n# Added by Zoi\nsetenv PATH=\"{}:{}\"\n",
447                zoi_bin_str, "$PATH"
448            );
449            (path, cmd)
450        } else {
451            let path = home.join(".profile");
452            let cmd = format!(
453                "\n# Added by Zoi\nexport PATH=\"{}:{}\"\n",
454                zoi_bin_str, "$PATH"
455            );
456            (path, cmd)
457        };
458
459        if !profile_file_path.exists() {
460            if let Some(parent) = profile_file_path.parent() {
461                fs::create_dir_all(parent)?;
462            }
463            File::create(&profile_file_path)?;
464        }
465
466        let content = fs::read_to_string(&profile_file_path)?;
467        if content.contains(zoi_bin_str.as_ref()) {
468            println!("Zoi bin directory is already in your shell's config.");
469            return Ok(());
470        }
471
472        let mut file =
473            OpenOptions::new().append(true).open(&profile_file_path)?;
474
475        file.write_all(cmd_to_write.as_bytes())?;
476
477        println!(
478            "{} Zoi bin directory has been added to your PATH in '{}'.",
479            "Success:".green(),
480            profile_file_path.display()
481        );
482        println!(
483            "Please restart your shell or run `source {}` for the changes to \
484             take effect.",
485            profile_file_path.display()
486        );
487    }
488
489    #[cfg(windows)]
490    {
491        use winreg::RegKey;
492        use winreg::enums::*;
493
494        let zoi_bin_path_str = zoi_bin_dir
495            .to_str()
496            .ok_or_else(|| anyhow!("Invalid path string"))?;
497
498        let (root, subkey, scope_name) = if scope == Scope::System {
499            if !is_admin() {
500                return Err(anyhow!(
501                    "Administrator privileges required to modify system PATH."
502                ));
503            }
504            (
505                HKEY_LOCAL_MACHINE,
506                "System\\CurrentControlSet\\Control\\Session \
507                 Manager\\Environment",
508                "system"
509            )
510        } else {
511            (HKEY_CURRENT_USER, "Environment", "user")
512        };
513
514        let key = RegKey::predef(root);
515        let env = key.open_subkey_with_flags(subkey, KEY_READ | KEY_WRITE)?;
516        let current_path: String = env.get_value("Path")?;
517
518        if current_path
519            .split(';')
520            .any(|p| p.eq_ignore_ascii_case(zoi_bin_path_str))
521        {
522            println!("Zoi bin directory is already in your PATH.");
523            return Ok(());
524        }
525
526        let new_path = if current_path.is_empty() {
527            zoi_bin_path_str.to_string()
528        } else {
529            format!("{};{}", current_path, zoi_bin_path_str)
530        };
531        env.set_value("Path", &new_path)?;
532
533        println!(
534            "{} Zoi bin directory has been added to your {} PATH environment \
535             variable.",
536            "Success:".green(),
537            scope_name
538        );
539        println!(
540            "Please restart your shell or log out and log back in for the \
541             changes to take effect."
542        );
543    }
544
545    Ok(())
546}
547
548/// Checks if the Zoi bin directory is in the current PATH and prints a warning
549/// if not.
550pub fn check_path() {
551    let Ok(zoi_bin_dir) = crate::pkg::utils::get_user_bin_dir() else {
552        return;
553    };
554    if !zoi_bin_dir.exists() {
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                std::env::split_paths(path_var.as_ref())
572                    .any(|path| path == zoi_bin_dir)
573            } else {
574                false
575            }
576        }
577        Err(_) => false
578    };
579
580    if !is_in_path {
581        eprintln!(
582            "Please run 'zoi shell <shell>' or add it to your PATH manually \
583             for commands to be available."
584        );
585    }
586}