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 if license.eq_ignore_ascii_case("Unkown") {
623 println!("{}", "Warning: Package license is unkown.".red());
624 return;
625 }
626
627 match spdx::Expression::parse(license) {
628 Ok(expr) => {
629 if !expr.evaluate(|req| match req.license {
630 spdx::LicenseItem::Spdx { id, .. } => id.is_osi_approved(),
631 spdx::LicenseItem::Other { .. } => false,
632 }) {
633 println!(
634 "{}{}{}",
635 "Warning: License '".yellow(),
636 license.yellow().bold(),
637 "' is not an OSI approved license.".yellow()
638 );
639 }
640 }
641 Err(_) => {
642 println!(
643 "{}{}{}",
644 "Warning: Could not parse license expression '".yellow(),
645 license.yellow().bold(),
646 "' It may not be a valid SPDX identifier.".yellow()
647 );
648 }
649 }
650}
651
652#[derive(serde::Deserialize)]
653struct PackageForCompletion {
654 description: Option<String>,
655}
656
657pub struct PackageCompletion {
658 pub display: String,
659 pub repo: String,
660 pub description: String,
661}
662
663pub fn get_all_packages_for_completion() -> Vec<PackageCompletion> {
664 let db_root = if let Ok(path) = crate::pkg::resolve::get_db_root() {
665 path
666 } else {
667 return Vec::new();
668 };
669
670 let active_repos = if let Ok(config) = crate::pkg::config::read_config() {
671 config.repos
672 } else {
673 return Vec::new();
674 };
675
676 if !db_root.exists() {
677 return Vec::new();
678 }
679
680 let mut packages = Vec::new();
681 for repo_name in &active_repos {
682 let repo_path = db_root.join(repo_name);
683 if !repo_path.is_dir() {
684 continue;
685 }
686 for entry in WalkDir::new(&repo_path)
687 .into_iter()
688 .filter_map(|e| e.ok())
689 .filter(|e| e.file_type().is_dir())
690 {
691 let pkg_name = entry.file_name().to_string_lossy();
692 let pkg_file_path = entry.path().join(format!("{}.pkg.lua", pkg_name));
693
694 if pkg_file_path.is_file() {
695 let pkg_info: anyhow::Result<PackageForCompletion> = (|| -> anyhow::Result<_> {
696 let pkg = crate::pkg::lua::parser::parse_lua_package(
697 pkg_file_path.to_str().unwrap(),
698 None,
699 )?;
700 Ok(PackageForCompletion {
701 description: Some(pkg.description),
702 })
703 })();
704
705 let description = match pkg_info {
706 Ok(pi) => pi.description.unwrap_or_default(),
707 Err(_) => String::new(),
708 };
709
710 let relative_path = entry.path().strip_prefix(&db_root).unwrap();
711 let full_pkg_id =
712 format!("@{}", relative_path.to_string_lossy().replace('\\', "/"));
713
714 packages.push(PackageCompletion {
715 display: full_pkg_id,
716 repo: repo_name.clone(),
717 description,
718 });
719 }
720 }
721 }
722 packages.sort_by(|a, b| a.display.cmp(&b.display));
723 packages
724}
725
726pub fn get_current_shell() -> Option<Shell> {
727 if cfg!(windows) {
728 return Some(Shell::PowerShell);
729 }
730
731 if let Ok(shell_path) = std::env::var("SHELL") {
732 let shell_name = Path::new(&shell_path).file_name()?.to_str()?;
733 match shell_name {
734 "bash" => Some(Shell::Bash),
735 "zsh" => Some(Shell::Zsh),
736 "fish" => Some(Shell::Fish),
737 "elvish" => Some(Shell::Elvish),
738 "pwsh" => Some(Shell::PowerShell),
739 _ => None,
740 }
741 } else {
742 None
743 }
744}