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;
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    if is_external_license_link(license) {
306        println!(
307            "{} The license is available at: {}",
308            "Warning:".yellow(),
309            license.cyan().bold()
310        );
311        return;
312    }
313
314    match spdx::Expression::parse(license) {
315        Ok(expr) => {
316            if !expr.evaluate(|req| match req.license {
317                spdx::LicenseItem::Spdx { id, .. } => id.is_osi_approved(),
318                spdx::LicenseItem::Other { .. } => false
319            }) {
320                println!(
321                    "{} License expression '{}' does not evaluate to an OSI \
322                     approved license.",
323                    "Warning:".yellow(),
324                    license.yellow().bold()
325                );
326            }
327        }
328        Err(_) => {
329            println!(
330                "{} Could not parse license expression '{}'. It may not be a \
331                 valid SPDX identifier.",
332                "Warning:".yellow(),
333                license.yellow().bold()
334            );
335        }
336    }
337}
338
339/// Returns `true` if the license value is an external link (a URL) pointing to
340/// the license text hosted elsewhere instead of an SPDX identifier.
341fn is_external_license_link(license: &str) -> bool {
342    let trimmed = license.trim();
343    trimmed.starts_with("http://") || trimmed.starts_with("https://")
344}
345
346/// Asks the user for confirmation with a prompt.
347pub fn ask_for_confirmation(prompt: &str, yes: bool) -> bool {
348    if yes {
349        return true;
350    }
351
352    if std::env::var("ZOI_TEST").is_ok() || !stdin().is_tty() {
353        return false;
354    }
355
356    print!("{prompt} [y/N]: ");
357    let _ = stdout().flush();
358    let mut input = String::new();
359    if stdin().read_line(&mut input).is_err() {
360        return false;
361    }
362    input.trim().eq_ignore_ascii_case("y")
363}
364
365/// Escapes a string for use inside an Elvish double-quoted string literal.
366///
367/// Backslashes are escaped first so that escaping the double quotes
368/// afterwards cannot produce double-escaped sequences.
369#[cfg(any(unix, test))]
370fn elvish_quote(value: &str) -> String {
371    value.replace('\\', "\\\\").replace('"', "\\\"")
372}
373
374/// Sets up the PATH environment variable for the given scope.
375///
376/// # Errors
377///
378/// Returns an error if:
379/// - The home directory cannot be found for the user scope.
380/// - The directory for binaries cannot be created.
381/// - The shell configuration file cannot be read, created, or written to.
382/// - On Windows, the registry key for environment variables cannot be opened or
383///   modified.
384/// - Administrator privileges are missing for system scope on Windows.
385pub fn setup_path(scope: Scope) -> anyhow::Result<()> {
386    if scope == Scope::Project {
387        return Ok(());
388    }
389
390    let zoi_bin_dir = match scope {
391        Scope::User => crate::pkg::utils::get_user_bin_dir()?,
392        Scope::System => crate::pkg::utils::get_system_bin_dir(),
393        Scope::Project => return Ok(())
394    };
395
396    if !zoi_bin_dir.exists() {
397        fs::create_dir_all(&zoi_bin_dir)?;
398    }
399
400    if scope == Scope::System && cfg!(unix) {
401        println!(
402            "{}",
403            "System-wide installation complete. Binaries are in the system \
404             PATH."
405                .green()
406        );
407        return Ok(());
408    }
409
410    #[cfg(unix)]
411    {
412        use std::fs::{File, OpenOptions};
413        let home = crate::pkg::utils::get_user_home()
414            .ok_or_else(|| anyhow!("Could not find home directory."))?;
415        let zoi_bin_str = zoi_bin_dir.to_string_lossy();
416
417        let shell_name = std::env::var("SHELL").unwrap_or_default();
418        let (profile_file_path, cmd_to_write) = if shell_name.contains("bash") {
419            let path = if cfg!(target_os = "macos") {
420                home.join(".bash_profile")
421            } else {
422                home.join(".bashrc")
423            };
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("zsh") {
430            let path = home.join(".zshrc");
431            let cmd = format!(
432                "\n# Added by Zoi\nexport PATH=\"{}:{}\"\n",
433                zoi_bin_str, "$PATH"
434            );
435            (path, cmd)
436        } else if shell_name.contains("fish") {
437            let path = home.join(".config/fish/config.fish");
438            let cmd =
439                format!("\n# Added by Zoi\nfish_add_path \"{zoi_bin_str}\"\n");
440            (path, cmd)
441        } else if shell_name.contains("elvish") {
442            let path = home.join(".config/elvish/rc.elv");
443            // Escape the path so it parses as one literal string value even
444            // when it contains whitespace, quotes, or backslashes.
445            let cmd = format!(
446                "\n# Added by Zoi\nset paths = [ \"{}\" $paths... ]\n",
447                elvish_quote(&zoi_bin_str)
448            );
449            (path, cmd)
450        } else if shell_name.contains("csh") || shell_name.contains("tcsh") {
451            let path = home.join(".cshrc");
452            let cmd = format!(
453                "\n# Added by Zoi\nsetenv PATH=\"{}:{}\"\n",
454                zoi_bin_str, "$PATH"
455            );
456            (path, cmd)
457        } else {
458            let path = home.join(".profile");
459            let cmd = format!(
460                "\n# Added by Zoi\nexport PATH=\"{}:{}\"\n",
461                zoi_bin_str, "$PATH"
462            );
463            (path, cmd)
464        };
465
466        if !profile_file_path.exists() {
467            if let Some(parent) = profile_file_path.parent() {
468                fs::create_dir_all(parent)?;
469            }
470            File::create(&profile_file_path)?;
471        }
472
473        let content = fs::read_to_string(&profile_file_path)?;
474        if content.contains(zoi_bin_str.as_ref()) {
475            println!("Zoi bin directory is already in your shell's config.");
476            return Ok(());
477        }
478
479        let mut file =
480            OpenOptions::new().append(true).open(&profile_file_path)?;
481
482        file.write_all(cmd_to_write.as_bytes())?;
483
484        println!(
485            "{} Zoi bin directory has been added to your PATH in '{}'.",
486            "Success:".green(),
487            profile_file_path.display()
488        );
489        println!(
490            "Please restart your shell or run `source {}` for the changes to \
491             take effect.",
492            profile_file_path.display()
493        );
494    }
495
496    #[cfg(windows)]
497    {
498        use winreg::RegKey;
499        use winreg::enums::*;
500
501        let zoi_bin_path_str = zoi_bin_dir
502            .to_str()
503            .ok_or_else(|| anyhow!("Invalid path string"))?;
504
505        let (root, subkey, scope_name) = if scope == Scope::System {
506            if !is_admin() {
507                return Err(anyhow!(
508                    "Administrator privileges required to modify system PATH."
509                ));
510            }
511            (
512                HKEY_LOCAL_MACHINE,
513                "System\\CurrentControlSet\\Control\\Session \
514                 Manager\\Environment",
515                "system"
516            )
517        } else {
518            (HKEY_CURRENT_USER, "Environment", "user")
519        };
520
521        let key = RegKey::predef(root);
522        let env = key.open_subkey_with_flags(subkey, KEY_READ | KEY_WRITE)?;
523        let current_path: String = env.get_value("Path")?;
524
525        if current_path
526            .split(';')
527            .any(|p| p.eq_ignore_ascii_case(zoi_bin_path_str))
528        {
529            println!("Zoi bin directory is already in your PATH.");
530            return Ok(());
531        }
532
533        let new_path = if current_path.is_empty() {
534            zoi_bin_path_str.to_string()
535        } else {
536            format!("{};{}", current_path, zoi_bin_path_str)
537        };
538        env.set_value("Path", &new_path)?;
539
540        println!(
541            "{} Zoi bin directory has been added to your {} PATH environment \
542             variable.",
543            "Success:".green(),
544            scope_name
545        );
546        println!(
547            "Please restart your shell or log out and log back in for the \
548             changes to take effect."
549        );
550    }
551
552    Ok(())
553}
554
555/// Checks if the Zoi bin directory is in the current PATH and prints a warning
556/// if not.
557pub fn check_path() {
558    let Ok(zoi_bin_dir) = crate::pkg::utils::get_user_bin_dir() else {
559        return;
560    };
561    if !zoi_bin_dir.exists() {
562        return;
563    }
564
565    let command_output = if cfg!(target_os = "windows") {
566        Command::new("pwsh")
567            .arg("-Command")
568            .arg("echo $env:Path")
569            .output()
570    } else {
571        Command::new("bash").arg("-c").arg("echo $PATH").output()
572    };
573
574    let is_in_path = match command_output {
575        Ok(output) => {
576            if output.status.success() {
577                let path_var = String::from_utf8_lossy(&output.stdout);
578                std::env::split_paths(path_var.as_ref())
579                    .any(|path| path == zoi_bin_dir)
580            } else {
581                false
582            }
583        }
584        Err(_) => false
585    };
586
587    if !is_in_path {
588        eprintln!(
589            "Please run 'zoi shell <shell>' or add it to your PATH manually \
590             for commands to be available."
591        );
592    }
593}