1use crate::pkg::resolve::SourceType;
2use anyhow::anyhow;
3use colored::*;
4use std::fmt::Display;
5use std::fs;
6use std::io::{Write, stdin, stdout};
7use std::process::Command;
8use std::time::Duration;
9use walkdir::WalkDir;
10
11use crate::pkg::types::Scope;
12use clap_complete::Shell;
13use std::path::{Path, PathBuf};
14
15pub fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
16 fs::create_dir_all(dst)?;
17 for entry in fs::read_dir(src)? {
18 let entry = entry?;
19 let ty = entry.file_type()?;
20 if ty.is_dir() {
21 copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
22 } else {
23 fs::copy(entry.path(), dst.join(entry.file_name()))?;
24 }
25 }
26 Ok(())
27}
28
29pub fn is_admin() -> bool {
30 #[cfg(windows)]
31 {
32 use std::mem;
33 use std::ptr;
34 use winapi::um::handleapi::CloseHandle;
35 use winapi::um::processthreadsapi::GetCurrentProcess;
36 use winapi::um::processthreadsapi::OpenProcessToken;
37 use winapi::um::securitybaseapi::CheckTokenMembership;
38 use winapi::um::winnt::{PSID, TOKEN_QUERY};
39
40 let mut token = ptr::null_mut();
41 let process = unsafe { GetCurrentProcess() };
42 if unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) } == 0 {
43 return false;
44 }
45
46 let mut sid: [u8; 8] = [0; 8];
47 let mut sid_size = mem::size_of_val(&sid) as u32;
48 if unsafe {
49 winapi::um::securitybaseapi::CreateWellKnownSid(
50 winapi::um::winnt::WinBuiltinAdministratorsSid,
51 ptr::null_mut(),
52 sid.as_mut_ptr() as PSID,
53 &mut sid_size,
54 )
55 } == 0
56 {
57 unsafe { CloseHandle(token) };
58 return false;
59 }
60
61 let mut is_member = 0;
62 let result =
63 unsafe { CheckTokenMembership(token, sid.as_mut_ptr() as PSID, &mut is_member) };
64 unsafe { CloseHandle(token) };
65
66 result != 0 && is_member != 0
67 }
68 #[cfg(unix)]
69 {
70 nix::unistd::getuid().is_root()
71 }
72}
73
74pub fn print_info<T: Display>(key: &str, value: T) {
75 println!("{}: {}", key, value);
76}
77
78pub fn format_version_summary(branch: &str, status: &str, number: &str) -> String {
79 let branch_short = if branch == "Production" {
80 "Prod."
81 } else if branch == "Development" {
82 "Dev."
83 } else if branch == "Public" {
84 "Pub."
85 } else if branch == "Special" {
86 "Spec."
87 } else {
88 branch
89 };
90 format!(
91 "{} {} {}",
92 branch_short.blue().bold().italic(),
93 status,
94 number,
95 )
96}
97
98pub fn format_version_full(branch: &str, status: &str, number: &str, commit: &str) -> String {
99 format!(
100 "{} {}",
101 format_version_summary(branch, status, number),
102 commit.green()
103 )
104}
105
106pub fn print_aligned_info(key: &str, value: &str) {
107 let key_with_colon = format!("{}:", key);
108 println!("{:<18}{}", key_with_colon.cyan(), value);
109}
110
111pub fn command_exists(command: &str) -> bool {
112 if cfg!(target_os = "windows") {
113 Command::new("where")
114 .arg(command)
115 .stdout(std::process::Stdio::null())
116 .stderr(std::process::Stdio::null())
117 .status()
118 .is_ok_and(|status| status.success())
119 } else {
120 Command::new("bash")
121 .arg("-c")
122 .arg(format!("command -v {}", command))
123 .stdout(std::process::Stdio::null())
124 .stderr(std::process::Stdio::null())
125 .status()
126 .is_ok_and(|status| status.success())
127 }
128}
129
130pub fn ask_for_confirmation(prompt: &str, yes: bool) -> bool {
131 if yes {
132 return true;
133 }
134 print!("{} [y/N]: ", prompt.yellow());
135 let _ = stdout().flush();
136 let mut input = String::new();
137 if stdin().read_line(&mut input).is_err() {
138 return false;
139 }
140 input.trim().eq_ignore_ascii_case("y")
141}
142
143use std::collections::HashMap;
144
145pub fn get_linux_distribution_info() -> Option<HashMap<String, String>> {
146 if let Ok(contents) = fs::read_to_string("/etc/os-release") {
147 let info: HashMap<String, String> = contents
148 .lines()
149 .filter_map(|line| {
150 let mut parts = line.splitn(2, '=');
151 let key = parts.next()?;
152 let value = parts.next()?.trim_matches('"').to_string();
153 if key.is_empty() {
154 None
155 } else {
156 Some((key.to_string(), value))
157 }
158 })
159 .collect();
160 if info.is_empty() { None } else { Some(info) }
161 } else {
162 None
163 }
164}
165
166pub fn get_linux_distro_family() -> Option<String> {
167 if let Some(info) = get_linux_distribution_info() {
168 if let Some(id_like) = info.get("ID_LIKE") {
169 let families: Vec<&str> = id_like.split_whitespace().collect();
170 if families.contains(&"debian") {
171 return Some("debian".to_string());
172 }
173 if families.contains(&"arch") {
174 return Some("arch".to_string());
175 }
176 if families.contains(&"fedora") {
177 return Some("fedora".to_string());
178 }
179 if families.contains(&"rhel") {
180 return Some("fedora".to_string());
181 }
182 if families.contains(&"suse") {
183 return Some("suse".to_string());
184 }
185 if families.contains(&"gentoo") {
186 return Some("gentoo".to_string());
187 }
188 }
189 if let Some(id) = info.get("ID") {
190 return match id.as_str() {
191 "debian" | "ubuntu" | "linuxmint" | "pop" | "kali" | "kubuntu" | "lubuntu"
192 | "xubuntu" | "zorin" | "elementary" => Some("debian".to_string()),
193 "arch" | "manjaro" | "cachyos" | "endeavouros" | "garuda" => {
194 Some("arch".to_string())
195 }
196 "fedora" | "centos" | "rhel" | "rocky" | "almalinux" => Some("fedora".to_string()),
197 "opensuse" | "opensuse-tumbleweed" | "opensuse-leap" => Some("suse".to_string()),
198 "gentoo" => Some("gentoo".to_string()),
199 "alpine" => Some("alpine".to_string()),
200 "void" => Some("void".to_string()),
201 "solus" => Some("solus".to_string()),
202 "guix" => Some("guix".to_string()),
203 _ => None,
204 };
205 }
206 }
207 None
208}
209
210pub fn get_linux_distribution() -> Option<String> {
211 get_linux_distribution_info().and_then(|info| info.get("ID").cloned())
212}
213
214pub fn get_native_package_manager() -> Option<String> {
215 let os = std::env::consts::OS;
216 match os {
217 "linux" => get_linux_distro_family()
218 .map(|family| {
219 match family.as_str() {
220 "debian" => "apt",
221 "arch" => "pacman",
222 "fedora" => "dnf",
223 "suse" => "zypper",
224 "gentoo" => "portage",
225 "alpine" => "apk",
226 "void" => "xbps-install",
227 "solus" => "eopkg",
228 "guix" => "guix",
229 _ => "unknown",
230 }
231 .to_string()
232 })
233 .filter(|s| s != "unknown"),
234 "macos" => {
235 if command_exists("brew") {
236 Some("brew".to_string())
237 } else if command_exists("port") {
238 Some("macports".to_string())
239 } else {
240 None
241 }
242 }
243 "windows" => {
244 if command_exists("scoop") {
245 Some("scoop".to_string())
246 } else if command_exists("choco") {
247 Some("choco".to_string())
248 } else if command_exists("winget") {
249 Some("winget".to_string())
250 } else {
251 None
252 }
253 }
254 "freebsd" => Some("pkg".to_string()),
255 "openbsd" => Some("pkg_add".to_string()),
256 _ => None,
257 }
258}
259
260pub fn print_repo_warning(repo_name: &str) {
261 if let Ok(db_path) = crate::pkg::resolve::get_db_root()
262 && let Ok(repo_config) = crate::pkg::config::read_repo_config(&db_path)
263 {
264 let major_repo = repo_name.split('/').next().unwrap_or("");
265 if let Some(repo_entry) = repo_config.repos.iter().find(|r| r.name == major_repo) {
266 let warning_message = match repo_entry.repo_type.as_str() {
267 "unoffical" => {
268 Some("This package is from an unofficial repository and is not trusted.")
269 }
270 "community" => {
271 Some("This package is from a community repository. Use with caution.")
272 }
273 "test" => Some(
274 "This package is from a testing repository and may not function correctly.",
275 ),
276 "archive" => {
277 Some("This package is from an archive repository and is no longer maintained.")
278 }
279 _ => None,
280 };
281
282 if let Some(message) = warning_message {
283 println!("\n{}: {}", "NOTE".yellow().bold(), message.yellow());
284 }
285 }
286 }
287}
288
289pub fn confirm_untrusted_source(source_type: &SourceType, yes: bool) -> anyhow::Result<()> {
290 if source_type == &SourceType::OfficialRepo {
291 return Ok(());
292 }
293
294 let warning_message = match source_type {
295 SourceType::UntrustedRepo(repo) => {
296 format!(
297 "The package from repository '@{}' is not an official Zoi repository.",
298 repo
299 )
300 }
301 SourceType::LocalFile => "You are installing from a local file.".to_string(),
302 SourceType::Url => "You are installing from a remote URL.".to_string(),
303 _ => return Ok(()),
304 };
305
306 println!(
307 "\n{}: {}",
308 "SECURITY WARNING".yellow().bold(),
309 warning_message
310 );
311
312 if ask_for_confirmation(
313 "This source is not trusted. Are you sure you want to continue?",
314 yes,
315 ) {
316 Ok(())
317 } else {
318 Err(anyhow!("Operation aborted by user."))
319 }
320}
321
322pub fn is_platform_compatible(current_platform: &str, allowed_platforms: &[String]) -> bool {
323 let os = match std::env::consts::OS {
324 "darwin" => "macos",
325 other => other,
326 };
327 allowed_platforms
328 .iter()
329 .any(|p| p == "all" || p == os || p == current_platform)
330}
331
332pub fn setup_path(scope: Scope) -> anyhow::Result<()> {
333 if scope == Scope::Project {
334 return Ok(());
335 }
336
337 let zoi_bin_dir = match scope {
338 Scope::User => home::home_dir()
339 .ok_or_else(|| anyhow!("Could not find home directory."))?
340 .join(".zoi")
341 .join("pkgs")
342 .join("bin"),
343 Scope::System => {
344 if cfg!(target_os = "windows") {
345 PathBuf::from("C:\\ProgramData\\zoi\\pkgs\\bin")
346 } else {
347 PathBuf::from("/usr/local/bin")
348 }
349 }
350 Scope::Project => return Ok(()),
351 };
352
353 if !zoi_bin_dir.exists() {
354 fs::create_dir_all(&zoi_bin_dir)?;
355 }
356
357 if scope == Scope::System {
358 println!(
359 "{}",
360 "System-wide installation complete. Binaries are in the system PATH.".green()
361 );
362 return Ok(());
363 }
364
365 println!("{}", "Ensuring Zoi bin directory is in your PATH...".bold());
366
367 #[cfg(unix)]
368 {
369 use std::fs::{File, OpenOptions};
370 let home = home::home_dir().ok_or_else(|| anyhow!("Could not find home directory."))?;
371 let zoi_bin_str = "$HOME/.zoi/pkgs/bin";
372
373 let shell_name = std::env::var("SHELL").unwrap_or_default();
374 let (profile_file_path, cmd_to_write) = if shell_name.contains("bash") {
375 let path = if cfg!(target_os = "macos") {
376 home.join(".bash_profile")
377 } else {
378 home.join(".bashrc")
379 };
380 let cmd = format!(
381 "\n# Added by Zoi\nexport PATH=\"{}:{}\"\n",
382 zoi_bin_str, "$PATH"
383 );
384 (path, cmd)
385 } else if shell_name.contains("zsh") {
386 let path = home.join(".zshrc");
387 let cmd = format!(
388 "\n# Added by Zoi\nexport PATH=\"{}:{}\"\n",
389 zoi_bin_str, "$PATH"
390 );
391 (path, cmd)
392 } else if shell_name.contains("fish") {
393 let path = home.join(".config/fish/config.fish");
394 let cmd = format!("\n# Added by Zoi\nset -gx PATH \"{}\" $PATH\n", zoi_bin_str);
395
396 (path, cmd)
397 } else if shell_name.contains("elvish") {
398 let path = home.join(".config/elvish/rc.elv");
399 let cmd = "
400# Added by Zoi
401set paths = [ ~/.zoi/pkgs/bin $paths... ]
402"
403 .to_string();
404 (path, cmd)
405 } else if shell_name.contains("csh") || shell_name.contains("tcsh") {
406 let path = home.join(".cshrc");
407 let cmd = format!(
408 "\n# Added by Zoi\nsetenv PATH=\"{}:{}\"\n",
409 zoi_bin_str, "$PATH"
410 );
411 (path, cmd)
412 } else {
413 let path = home.join(".profile");
414 let cmd = format!(
415 "\n# Added by Zoi\nexport PATH=\"{}:{}\"\n",
416 zoi_bin_str, "$PATH"
417 );
418 (path, cmd)
419 };
420
421 if !profile_file_path.exists() {
422 if let Some(parent) = profile_file_path.parent() {
423 fs::create_dir_all(parent)?;
424 }
425 File::create(&profile_file_path)?;
426 }
427
428 let content = fs::read_to_string(&profile_file_path)?;
429 if content.contains(zoi_bin_str) {
430 println!("Zoi bin directory is already in your shell's config.");
431 return Ok(());
432 }
433
434 let mut file = OpenOptions::new().append(true).open(&profile_file_path)?;
435
436 file.write_all(cmd_to_write.as_bytes())?;
437
438 println!(
439 "{} Zoi bin directory has been added to your PATH in '{}'.",
440 "Success:".green(),
441 profile_file_path.display()
442 );
443 println!(
444 "Please restart your shell or run `source {}` for the changes to take effect.",
445 profile_file_path.display()
446 );
447 }
448
449 #[cfg(windows)]
450 {
451 use winreg::RegKey;
452 use winreg::enums::*;
453
454 let zoi_bin_path_str = zoi_bin_dir
455 .to_str()
456 .ok_or_else(|| anyhow!("Invalid path string"))?;
457
458 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
459 let env = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)?;
460 let current_path: String = env.get_value("Path")?;
461
462 if current_path
463 .split(';')
464 .any(|p| p.eq_ignore_ascii_case(zoi_bin_path_str))
465 {
466 println!("Zoi bin directory is already in your PATH.");
467 return Ok(());
468 }
469
470 let new_path = if current_path.is_empty() {
471 zoi_bin_path_str.to_string()
472 } else {
473 format!("{};{}", current_path, zoi_bin_path_str)
474 };
475 env.set_value("Path", &new_path)?;
476
477 println!(
478 "{} Zoi bin directory has been added to your user PATH environment variable.",
479 "Success:".green()
480 );
481 println!(
482 "Please restart your shell or log out and log back in for the changes to take effect."
483 );
484 }
485
486 Ok(())
487}
488
489pub fn check_path() {
490 if let Some(home) = home::home_dir() {
491 let zoi_bin_dir = home.join(".zoi/pkgs/bin");
492 if !zoi_bin_dir.exists() {
493 return;
494 }
495 } else {
496 return;
497 }
498
499 let command_output = if cfg!(target_os = "windows") {
500 Command::new("pwsh")
501 .arg("-Command")
502 .arg("echo $env:Path")
503 .output()
504 } else {
505 Command::new("bash").arg("-c").arg("echo $PATH").output()
506 };
507
508 let is_in_path = match command_output {
509 Ok(output) => {
510 if output.status.success() {
511 let path_var = String::from_utf8_lossy(&output.stdout);
512 path_var.contains(".zoi/pkgs/bin")
513 } else {
514 false
515 }
516 }
517 Err(_) => false,
518 };
519
520 if !is_in_path {
521 eprintln!(
522 "Please run 'zoi setup --scope user' or add it to your PATH manually for commands to be available."
523 );
524 }
525}
526
527pub fn get_platform() -> anyhow::Result<String> {
528 let os = match std::env::consts::OS {
529 "linux" => "linux",
530 "macos" | "darwin" => "macos",
531 "windows" => "windows",
532 "freebsd" => "freebsd",
533 "openbsd" => "openbsd",
534 unsupported_os => return Err(anyhow!("Unsupported operating system: {}", unsupported_os)),
535 };
536
537 let arch = match std::env::consts::ARCH {
538 "x86_64" => "amd64",
539 "aarch64" => "arm64",
540 unsupported_arch => return Err(anyhow!("Unsupported architecture: {}", unsupported_arch)),
541 };
542
543 Ok(format!("{}-{}", os, arch))
544}
545
546pub fn get_all_available_package_managers() -> Vec<String> {
547 let mut managers = Vec::new();
548 let all_possible_managers = [
549 "apt",
550 "apt-get",
551 "pacman",
552 "yay",
553 "paru",
554 "pikaur",
555 "trizen",
556 "dnf",
557 "yum",
558 "zypper",
559 "portage",
560 "apk",
561 "snap",
562 "flatpak",
563 "nix",
564 "brew",
565 "port",
566 "scoop",
567 "choco",
568 "winget",
569 "pkg",
570 "pkg_add",
571 "xbps-install",
572 "eopkg",
573 "guix",
574 "mas",
575 ];
576
577 for manager in &all_possible_managers {
578 if command_exists(manager) {
579 managers.push(manager.to_string());
580 }
581 }
582 managers.sort();
583 managers.dedup();
584 managers
585}
586
587pub fn build_blocking_http_client(timeout_secs: u64) -> anyhow::Result<reqwest::blocking::Client> {
588 let client = reqwest::blocking::Client::builder()
589 .timeout(Duration::from_secs(timeout_secs))
590 .build()?;
591 Ok(client)
592}
593
594pub fn retry_backoff_sleep(attempt: u32) {
595 let base_ms = 500u64.saturating_mul(1u64 << (attempt.saturating_sub(1)));
596 let jitter = (std::time::SystemTime::now()
597 .duration_since(std::time::UNIX_EPOCH)
598 .unwrap_or(Duration::from_secs(0))
599 .subsec_millis()
600 % 200) as u64;
601 let sleep_ms = (base_ms + jitter).min(8000);
602 std::thread::sleep(Duration::from_millis(sleep_ms));
603}
604
605pub fn check_license(license: &str) {
606 if license.is_empty() {
607 println!(
608 "{}",
609 "Warning: Package does not have a license specified.".yellow()
610 );
611 return;
612 }
613
614 if license.eq_ignore_ascii_case("Proprietary") {
615 println!(
616 "{}",
617 "Warning: Package is using a proprietary license.".red()
618 );
619 return;
620 }
621
622 match spdx::Expression::parse(license) {
623 Ok(expr) => {
624 if !expr.evaluate(|req| match req.license {
625 spdx::LicenseItem::Spdx { id, .. } => id.is_osi_approved(),
626 spdx::LicenseItem::Other { .. } => false,
627 }) {
628 println!(
629 "{}{}{}",
630 "Warning: License '".yellow(),
631 license.yellow().bold(),
632 "' is not an OSI approved license.".yellow()
633 );
634 }
635 }
636 Err(_) => {
637 println!(
638 "{}{}{}",
639 "Warning: Could not parse license expression '".yellow(),
640 license.yellow().bold(),
641 "' It may not be a valid SPDX identifier.".yellow()
642 );
643 }
644 }
645}
646
647#[derive(serde::Deserialize)]
648struct PackageForCompletion {
649 description: Option<String>,
650}
651
652pub struct PackageCompletion {
653 pub display: String,
654 pub repo: String,
655 pub description: String,
656}
657
658pub fn get_all_packages_for_completion() -> Vec<PackageCompletion> {
659 let db_root = if let Ok(path) = crate::pkg::resolve::get_db_root() {
660 path
661 } else {
662 return Vec::new();
663 };
664
665 let active_repos = if let Ok(config) = crate::pkg::config::read_config() {
666 config.repos
667 } else {
668 return Vec::new();
669 };
670
671 if !db_root.exists() {
672 return Vec::new();
673 }
674
675 let mut packages = Vec::new();
676 for repo_name in &active_repos {
677 let repo_path = db_root.join(repo_name);
678 if !repo_path.is_dir() {
679 continue;
680 }
681 for entry in WalkDir::new(&repo_path)
682 .into_iter()
683 .filter_map(|e| e.ok())
684 .filter(|e| e.file_type().is_dir())
685 {
686 let pkg_name = entry.file_name().to_string_lossy();
687 let pkg_file_path = entry.path().join(format!("{}.pkg.lua", pkg_name));
688
689 if pkg_file_path.is_file() {
690 let pkg_info: anyhow::Result<PackageForCompletion> = (|| -> anyhow::Result<_> {
691 let pkg = crate::pkg::lua::parser::parse_lua_package(
692 pkg_file_path.to_str().unwrap(),
693 None,
694 )?;
695 Ok(PackageForCompletion {
696 description: Some(pkg.description),
697 })
698 })();
699
700 let description = match pkg_info {
701 Ok(pi) => pi.description.unwrap_or_default(),
702 Err(_) => String::new(),
703 };
704
705 let relative_path = entry.path().strip_prefix(&db_root).unwrap();
706 let full_pkg_id =
707 format!("@{}", relative_path.to_string_lossy().replace('\\', "/"));
708
709 packages.push(PackageCompletion {
710 display: full_pkg_id,
711 repo: repo_name.clone(),
712 description,
713 });
714 }
715 }
716 }
717 packages.sort_by(|a, b| a.display.cmp(&b.display));
718 packages
719}
720
721pub fn get_current_shell() -> Option<Shell> {
722 if cfg!(windows) {
723 return Some(Shell::PowerShell);
724 }
725
726 if let Ok(shell_path) = std::env::var("SHELL") {
727 let shell_name = Path::new(&shell_path).file_name()?.to_str()?;
728 match shell_name {
729 "bash" => Some(Shell::Bash),
730 "zsh" => Some(Shell::Zsh),
731 "fish" => Some(Shell::Fish),
732 "elvish" => Some(Shell::Elvish),
733 "pwsh" => Some(Shell::PowerShell),
734 _ => None,
735 }
736 } else {
737 None
738 }
739}