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