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