1use crate::pkg::{config, pin, types};
2use anyhow::{Result, anyhow};
3use chrono::Utc;
4use colored::*;
5use dialoguer::{Select, theme::ColorfulTheme};
6use indicatif::{ProgressBar, ProgressStyle};
7use regex::Regex;
8use std::collections::HashMap;
9use std::env;
10use std::fs;
11use std::io::Read;
12use std::path::{Path, PathBuf};
13use walkdir::WalkDir;
14
15#[derive(Debug, PartialEq, Eq, Clone)]
16pub enum SourceType {
17 OfficialRepo,
18 UntrustedRepo(String),
19 GitRepo(String),
20 LocalFile,
21 Url,
22}
23
24#[derive(Debug)]
25pub struct ResolvedSource {
26 pub path: PathBuf,
27 pub source_type: SourceType,
28 pub repo_name: Option<String>,
29 pub registry_handle: Option<String>,
30 pub sharable_manifest: Option<types::SharableInstallManifest>,
31}
32
33#[derive(Debug, Default)]
34pub struct PackageRequest {
35 pub handle: Option<String>,
36 pub repo: Option<String>,
37 pub name: String,
38 pub sub_package: Option<String>,
39 pub version_spec: Option<String>,
40}
41
42pub fn get_db_root() -> Result<PathBuf> {
43 let home_dir = home::home_dir().ok_or_else(|| anyhow!("Could not find home directory."))?;
44 Ok(home_dir.join(".zoi").join("pkgs").join("db"))
45}
46
47pub fn parse_source_string(source_str: &str) -> Result<PackageRequest> {
48 let (path_part, sub_package_from_path) = if let Some((base, sub)) = source_str.rsplit_once(':')
49 {
50 if (base.ends_with(".pkg.lua") || base.ends_with(".manifest.yaml")) && !sub.contains('/') {
51 (base, Some(sub.to_string()))
52 } else {
53 (source_str, None)
54 }
55 } else {
56 (source_str, None)
57 };
58
59 if path_part.contains('/')
60 && (path_part.ends_with(".manifest.yaml") || path_part.ends_with(".pkg.lua"))
61 {
62 let path = std::path::Path::new(path_part);
63 let file_stem = path.file_stem().unwrap_or_default().to_string_lossy();
64 let name = if let Some(stripped) = file_stem.strip_suffix(".manifest") {
65 stripped.to_string()
66 } else if let Some(stripped) = file_stem.strip_suffix(".pkg") {
67 stripped.to_string()
68 } else {
69 file_stem.to_string()
70 };
71 return Ok(PackageRequest {
72 handle: None,
73 repo: None,
74 name,
75 sub_package: sub_package_from_path,
76 version_spec: None,
77 });
78 }
79
80 let re = Regex::new(r"^(?:#(?P<handle>[^@]+))?(?P<main_part>.*)$").unwrap();
81
82 let caps = re
83 .captures(source_str)
84 .ok_or_else(|| anyhow!("Invalid source string format"))?;
85 let handle = caps.name("handle").map(|m| m.as_str().to_string());
86 let main_part = caps.name("main_part").unwrap().as_str();
87
88 let re_main = Regex::new(r"^@?(?P<repo_and_name>[^@]+)(?:@(?P<version>.+))?$").unwrap();
89 let caps_main = re_main
90 .captures(main_part)
91 .ok_or_else(|| anyhow!("Invalid source string format"))?;
92
93 let repo_and_name = caps_main.name("repo_and_name").unwrap().as_str();
94 let version_spec = caps_main.name("version").map(|m| m.as_str().to_string());
95
96 let (repo, name_and_sub) = if main_part.starts_with('@') {
97 if let Some(slash_pos) = repo_and_name.find('/') {
98 let (repo_str, name_str) = repo_and_name.split_at(slash_pos);
99 (Some(repo_str.to_lowercase()), &name_str[1..])
100 } else {
101 return Err(anyhow!("Invalid repo format: expected @repo/name"));
102 }
103 } else {
104 (None, repo_and_name)
105 };
106
107 let (name, sub_package) = if let Some((n, s)) = name_and_sub.rsplit_once(':') {
108 (n, Some(s.to_string()))
109 } else {
110 (name_and_sub, None)
111 };
112
113 if name.is_empty() {
114 return Err(anyhow!("Invalid source string: package name is empty."));
115 }
116
117 Ok(PackageRequest {
118 handle,
119 repo,
120 name: name.to_lowercase(),
121 sub_package,
122 version_spec,
123 })
124}
125
126fn find_package_in_db(request: &PackageRequest, quiet: bool) -> Result<ResolvedSource> {
127 let db_root = get_db_root()?;
128 let config = config::read_config()?;
129
130 let (registry_db_path, search_repos, is_default_registry, registry_handle) =
131 if let Some(h) = &request.handle {
132 let is_default = config
133 .default_registry
134 .as_ref()
135 .is_some_and(|reg| reg.handle == *h);
136
137 if is_default {
138 let default_registry = config
139 .default_registry
140 .as_ref()
141 .ok_or_else(|| anyhow!("Default registry not found"))?;
142 (
143 db_root.join(&default_registry.handle),
144 config.repos,
145 true,
146 Some(default_registry.handle.clone()),
147 )
148 } else if let Some(registry) = config.added_registries.iter().find(|r| r.handle == *h) {
149 let repo_path = db_root.join(®istry.handle);
150 let all_sub_repos = if repo_path.exists() {
151 fs::read_dir(&repo_path)?
152 .filter_map(Result::ok)
153 .filter(|entry| entry.path().is_dir() && entry.file_name() != ".git")
154 .map(|entry| entry.file_name().to_string_lossy().into_owned())
155 .collect()
156 } else {
157 Vec::new()
158 };
159 (
160 repo_path,
161 all_sub_repos,
162 false,
163 Some(registry.handle.clone()),
164 )
165 } else {
166 return Err(anyhow!("Registry with handle '{}' not found.", h));
167 }
168 } else {
169 let default_registry = config
170 .default_registry
171 .as_ref()
172 .ok_or_else(|| anyhow!("No default registry set."))?;
173 (
174 db_root.join(&default_registry.handle),
175 config.repos,
176 true,
177 Some(default_registry.handle.clone()),
178 )
179 };
180
181 let repos_to_search = if let Some(r) = &request.repo {
182 vec![r.clone()]
183 } else {
184 search_repos
185 };
186
187 struct FoundPackage {
188 path: PathBuf,
189 source_type: SourceType,
190 repo_name: String,
191 description: String,
192 }
193
194 fn process_found_package(
195 path: PathBuf,
196 repo_name: &str,
197 is_default_registry: bool,
198 registry_db_path: &Path,
199 quiet: bool,
200 ) -> Result<FoundPackage> {
201 let pkg: types::Package = crate::pkg::lua::parser::parse_lua_package(
202 path.to_str()
203 .ok_or_else(|| anyhow!("Path contains invalid UTF-8 characters: {:?}", path))?,
204 None,
205 quiet,
206 )?;
207 let major_repo = repo_name
208 .split('/')
209 .next()
210 .unwrap_or_default()
211 .to_lowercase();
212
213 let source_type = if is_default_registry {
214 let repo_config = config::read_repo_config(registry_db_path).ok();
215 if let Some(ref cfg) = repo_config {
216 if let Some(repo_entry) = cfg.repos.iter().find(|r| r.name == major_repo) {
217 if repo_entry.repo_type == "offical" {
218 SourceType::OfficialRepo
219 } else {
220 SourceType::UntrustedRepo(repo_name.to_string())
221 }
222 } else {
223 SourceType::UntrustedRepo(repo_name.to_string())
224 }
225 } else {
226 SourceType::UntrustedRepo(repo_name.to_string())
227 }
228 } else {
229 SourceType::UntrustedRepo(repo_name.to_string())
230 };
231
232 Ok(FoundPackage {
233 path,
234 source_type,
235 repo_name: pkg.repo.clone(),
236 description: pkg.description,
237 })
238 }
239
240 let mut found_packages = Vec::new();
241
242 if request.name.contains('/') {
243 let pkg_name = Path::new(&request.name)
244 .file_name()
245 .and_then(|s| s.to_str())
246 .ok_or_else(|| anyhow!("Invalid package path: {}", request.name))?;
247
248 for repo_name in &repos_to_search {
249 let path = registry_db_path
250 .join(repo_name)
251 .join(&request.name)
252 .join(format!("{}.pkg.lua", pkg_name));
253
254 if path.exists()
255 && let Ok(found) = process_found_package(
256 path,
257 repo_name,
258 is_default_registry,
259 ®istry_db_path,
260 quiet,
261 )
262 {
263 found_packages.push(found);
264 }
265 }
266 } else {
267 for repo_name in &repos_to_search {
268 let repo_path = registry_db_path.join(repo_name);
269 if !repo_path.is_dir() {
270 continue;
271 }
272 for entry in WalkDir::new(&repo_path)
273 .into_iter()
274 .filter_map(|e| e.ok())
275 .filter(|e| e.file_type().is_dir() && e.file_name() == request.name.as_str())
276 {
277 let pkg_dir_path = entry.path();
278
279 if let Ok(relative_path) = pkg_dir_path.strip_prefix(&repo_path) {
280 if relative_path.components().count() > 1 {
281 continue;
282 }
283 } else {
284 continue;
285 }
286
287 let pkg_file_path = pkg_dir_path.join(format!("{}.pkg.lua", request.name));
288
289 if pkg_file_path.exists()
290 && let Ok(found) = process_found_package(
291 pkg_file_path,
292 repo_name,
293 is_default_registry,
294 ®istry_db_path,
295 quiet,
296 )
297 {
298 found_packages.push(found);
299 }
300 }
301 }
302 }
303
304 if found_packages.is_empty() {
305 for repo_name in &repos_to_search {
306 let repo_path = registry_db_path.join(repo_name);
307 if !repo_path.is_dir() {
308 continue;
309 }
310 for entry in WalkDir::new(&repo_path)
311 .into_iter()
312 .filter_map(|e| e.ok())
313 .filter(|e| {
314 e.file_type().is_file() && e.file_name().to_string_lossy().ends_with(".pkg.lua")
315 })
316 {
317 if let Ok(pkg) = crate::pkg::lua::parser::parse_lua_package(
318 entry.path().to_str().ok_or_else(|| {
319 anyhow!("Path contains invalid UTF-8 characters: {:?}", entry.path())
320 })?,
321 None,
322 true,
323 ) && let Some(provides) = &pkg.provides
324 && provides.iter().any(|p| p == &request.name)
325 && let Some(provides) = &pkg.provides
326 && provides.iter().any(|p| p == &request.name)
327 {
328 let major_repo = repo_name
329 .split('/')
330 .next()
331 .unwrap_or_default()
332 .to_lowercase();
333 let source_type = if is_default_registry {
334 let repo_config = config::read_repo_config(®istry_db_path).ok();
335 if let Some(ref cfg) = repo_config {
336 if let Some(repo_entry) =
337 cfg.repos.iter().find(|r| r.name == major_repo)
338 {
339 if repo_entry.repo_type == "offical" {
340 SourceType::OfficialRepo
341 } else {
342 SourceType::UntrustedRepo(repo_name.clone())
343 }
344 } else {
345 SourceType::UntrustedRepo(repo_name.clone())
346 }
347 } else {
348 SourceType::UntrustedRepo(repo_name.clone())
349 }
350 } else {
351 SourceType::UntrustedRepo(repo_name.clone())
352 };
353 found_packages.push(FoundPackage {
354 path: entry.path().to_path_buf(),
355 source_type,
356 repo_name: pkg.repo.clone(),
357 description: pkg.description,
358 });
359 }
360 }
361 }
362 }
363
364 if found_packages.is_empty() {
365 if let Some(repo) = &request.repo {
366 Err(anyhow!(
367 "Package '{}' not found in repository '@{}'.",
368 request.name,
369 repo
370 ))
371 } else {
372 Err(anyhow!(
373 "Package '{}' not found in any active repositories.",
374 request.name
375 ))
376 }
377 } else if found_packages.len() == 1 {
378 let chosen = &found_packages[0];
379
380 Ok(ResolvedSource {
381 path: chosen.path.clone(),
382 source_type: chosen.source_type.clone(),
383 repo_name: Some(chosen.repo_name.clone()),
384 registry_handle: registry_handle.clone(),
385 sharable_manifest: None,
386 })
387 } else {
388 println!(
389 "Found multiple packages named '{}'. Please choose one:",
390 request.name.cyan()
391 );
392
393 let items: Vec<String> = found_packages
394 .iter()
395 .map(|p| format!("@{} - {}", p.repo_name.bold(), p.description))
396 .collect();
397
398 let selection = Select::with_theme(&ColorfulTheme::default())
399 .with_prompt("Select a package")
400 .items(&items)
401 .default(0)
402 .interact()?;
403
404 let chosen = &found_packages[selection];
405 println!(
406 "Selected package '{}' from repo '{}'",
407 request.name, chosen.repo_name
408 );
409
410 Ok(ResolvedSource {
411 path: chosen.path.clone(),
412 source_type: chosen.source_type.clone(),
413 repo_name: Some(chosen.repo_name.clone()),
414 registry_handle: registry_handle.clone(),
415 sharable_manifest: None,
416 })
417 }
418}
419
420fn download_from_url(url: &str) -> Result<ResolvedSource> {
421 println!("Downloading package definition from URL...");
422 let client = crate::utils::build_blocking_http_client(20)?;
423 let mut attempt = 0u32;
424 let mut response = loop {
425 attempt += 1;
426 match client.get(url).send() {
427 Ok(resp) => break resp,
428 Err(e) => {
429 if attempt < 3 {
430 eprintln!(
431 "{}: download failed ({}). Retrying...",
432 "Network".yellow(),
433 e
434 );
435 crate::utils::retry_backoff_sleep(attempt);
436 continue;
437 } else {
438 return Err(anyhow!(
439 "Failed to download file after {} attempts: {}",
440 attempt,
441 e
442 ));
443 }
444 }
445 }
446 };
447 if !response.status().is_success() {
448 return Err(anyhow!(
449 "Failed to download file (HTTP {}): {}",
450 response.status(),
451 url
452 ));
453 }
454
455 let total_size = response.content_length().unwrap_or(0);
456 let pb = ProgressBar::new(total_size);
457 pb.set_style(ProgressStyle::default_bar()
458 .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec})")?
459 .progress_chars("#>-"));
460
461 let mut downloaded_bytes = Vec::new();
462 let mut buffer = [0; 8192];
463 loop {
464 let bytes_read = response.read(&mut buffer)?;
465 if bytes_read == 0 {
466 break;
467 }
468 downloaded_bytes.extend_from_slice(&buffer[..bytes_read]);
469 pb.inc(bytes_read as u64);
470 }
471 pb.finish_with_message("Download complete.");
472
473 let content = String::from_utf8(downloaded_bytes)?;
474
475 let temp_path = env::temp_dir().join(format!(
476 "zoi-temp-{}.pkg.lua",
477 Utc::now().timestamp_nanos_opt().unwrap_or(0)
478 ));
479 fs::write(&temp_path, content)?;
480
481 Ok(ResolvedSource {
482 path: temp_path,
483 source_type: SourceType::Url,
484 repo_name: None,
485 registry_handle: Some("local".to_string()),
486 sharable_manifest: None,
487 })
488}
489
490fn download_content_from_url(url: &str) -> Result<String> {
491 println!("Downloading from: {}", url.cyan());
492 let client = crate::utils::build_blocking_http_client(20)?;
493 let mut attempt = 0u32;
494 let response = loop {
495 attempt += 1;
496 match client.get(url).send() {
497 Ok(resp) => break resp,
498 Err(e) => {
499 if attempt < 3 {
500 eprintln!(
501 "{}: download failed ({}). Retrying...",
502 "Network".yellow(),
503 e
504 );
505 crate::utils::retry_backoff_sleep(attempt);
506 continue;
507 } else {
508 return Err(anyhow!(
509 "Failed to download from {} after {} attempts: {}",
510 url,
511 attempt,
512 e
513 ));
514 }
515 }
516 }
517 };
518
519 if !response.status().is_success() {
520 return Err(anyhow!(
521 "Failed to download from {} (HTTP {}). Content: {}",
522 url,
523 response.status(),
524 response
525 .text()
526 .unwrap_or_else(|_| "Could not read response body".to_string())
527 ));
528 }
529
530 Ok(response.text()?)
531}
532
533fn resolve_version_from_url(url: &str, channel: &str) -> Result<String> {
534 println!(
535 "Resolving version for channel '{}' from {}",
536 channel.cyan(),
537 url.cyan()
538 );
539 let client = crate::utils::build_blocking_http_client(15)?;
540 let mut attempt = 0u32;
541 let resp = loop {
542 attempt += 1;
543 match client.get(url).send() {
544 Ok(r) => match r.text() {
545 Ok(t) => break t,
546 Err(e) => {
547 if attempt < 3 {
548 eprintln!("{}: read failed ({}). Retrying...", "Network".yellow(), e);
549 crate::utils::retry_backoff_sleep(attempt);
550 continue;
551 } else {
552 return Err(anyhow!(
553 "Failed to read response after {} attempts: {}",
554 attempt,
555 e
556 ));
557 }
558 }
559 },
560 Err(e) => {
561 if attempt < 3 {
562 eprintln!("{}: fetch failed ({}). Retrying...", "Network".yellow(), e);
563 crate::utils::retry_backoff_sleep(attempt);
564 continue;
565 } else {
566 return Err(anyhow!("Failed to fetch after {} attempts: {}", attempt, e));
567 }
568 }
569 }
570 };
571 let json: serde_json::Value = serde_json::from_str(&resp)?;
572
573 if let Some(version) = json
574 .get("versions")
575 .and_then(|v| v.get(channel))
576 .and_then(|c| c.as_str())
577 {
578 return Ok(version.to_string());
579 }
580
581 Err(anyhow!(
582 "Failed to extract version for channel '{channel}' from JSON URL: {url}"
583 ))
584}
585
586fn resolve_channel(versions: &HashMap<String, String>, channel: &str) -> Result<String> {
587 if let Some(url_or_version) = versions.get(channel) {
588 if url_or_version.starts_with("http") {
589 resolve_version_from_url(url_or_version, channel)
590 } else {
591 Ok(url_or_version.clone())
592 }
593 } else {
594 Err(anyhow!("Channel '@{}' not found in versions map.", channel))
595 }
596}
597
598pub fn get_default_version(pkg: &types::Package, registry_handle: Option<&str>) -> Result<String> {
599 if let Some(handle) = registry_handle {
600 let source = format!("#{}@{}", handle, pkg.repo);
601
602 if let Some(pinned_version) = pin::get_pinned_version(&source)? {
603 println!(
604 "Using pinned version '{}' for {}.",
605 pinned_version.yellow(),
606 source.cyan()
607 );
608 return if pinned_version.starts_with('@') {
609 let channel = pinned_version.trim_start_matches('@');
610 let versions = pkg.versions.as_ref().ok_or_else(|| {
611 anyhow!(
612 "Package '{}' has no 'versions' map to resolve pinned channel '{}'.",
613 pkg.name,
614 pinned_version
615 )
616 })?;
617 resolve_channel(versions, channel)
618 } else {
619 Ok(pinned_version)
620 };
621 }
622 }
623
624 if let Some(versions) = &pkg.versions {
625 if versions.contains_key("stable") {
626 return resolve_channel(versions, "stable");
627 }
628 if let Some((channel, _)) = versions.iter().next() {
629 println!(
630 "No 'stable' channel found, using first available channel: '@{}'",
631 channel.cyan()
632 );
633 return resolve_channel(versions, channel);
634 }
635 return Err(anyhow!(
636 "Package has a 'versions' map but no versions were found in it."
637 ));
638 }
639
640 if let Some(ver) = &pkg.version {
641 if ver.starts_with("http") {
642 let client = crate::utils::build_blocking_http_client(15)?;
643 let mut attempt = 0u32;
644 let resp = loop {
645 attempt += 1;
646 match client.get(ver).send() {
647 Ok(r) => match r.text() {
648 Ok(t) => break t,
649 Err(e) => {
650 if attempt < 3 {
651 eprintln!(
652 "{}: read failed ({}). Retrying...",
653 "Network".yellow(),
654 e
655 );
656 crate::utils::retry_backoff_sleep(attempt);
657 continue;
658 } else {
659 return Err(anyhow!(
660 "Failed to read response after {} attempts: {}",
661 attempt,
662 e
663 ));
664 }
665 }
666 },
667 Err(e) => {
668 if attempt < 3 {
669 eprintln!("{}: fetch failed ({}). Retrying...", "Network".yellow(), e);
670 crate::utils::retry_backoff_sleep(attempt);
671 continue;
672 } else {
673 return Err(anyhow!(
674 "Failed to fetch after {} attempts: {}",
675 attempt,
676 e
677 ));
678 }
679 }
680 }
681 };
682 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&resp) {
683 if let Some(version) = json
684 .get("versions")
685 .and_then(|v| v.get("stable"))
686 .and_then(|s| s.as_str())
687 {
688 return Ok(version.to_string());
689 }
690
691 if let Some(tag) = json
692 .get("latest")
693 .and_then(|l| l.get("production"))
694 .and_then(|p| p.get("tag"))
695 .and_then(|t| t.as_str())
696 {
697 return Ok(tag.to_string());
698 }
699 return Err(anyhow!(
700 "Could not determine a version from the JSON content at {}",
701 ver
702 ));
703 }
704 return Ok(resp.trim().to_string());
705 } else {
706 return Ok(ver.clone());
707 }
708 }
709
710 Err(anyhow!(
711 "Could not determine a version for package '{}'.",
712 pkg.name
713 ))
714}
715
716fn get_version_for_install(
717 pkg: &types::Package,
718 version_spec: &Option<String>,
719 registry_handle: Option<&str>,
720) -> Result<String> {
721 if let Some(spec) = version_spec {
722 if spec.starts_with('@') {
723 let channel = spec.trim_start_matches('@');
724 let versions = pkg.versions.as_ref().ok_or_else(|| {
725 anyhow!(
726 "Package '{}' has no 'versions' map to resolve channel '@{}'.",
727 pkg.name,
728 channel
729 )
730 })?;
731 return resolve_channel(versions, channel);
732 }
733
734 if let Some(versions) = &pkg.versions
735 && versions.contains_key(spec)
736 {
737 println!("Found '{}' as a channel, resolving...", spec.cyan());
738 return resolve_channel(versions, spec);
739 }
740
741 return Ok(spec.clone());
742 }
743
744 get_default_version(pkg, registry_handle)
745}
746
747pub fn resolve_source(source: &str, quiet: bool) -> Result<ResolvedSource> {
748 let resolved = resolve_source_recursive(source, 0, quiet)?;
749
750 if let Ok(request) = parse_source_string(source)
751 && !matches!(
752 &resolved.source_type,
753 SourceType::LocalFile | SourceType::Url
754 )
755 && let Some(repo_name) = &resolved.repo_name
756 {
757 println!("Found package '{}' in repo '{}'", request.name, repo_name);
758 }
759
760 Ok(resolved)
761}
762
763pub fn resolve_package_and_version(
764 source_str: &str,
765 quiet: bool,
766) -> Result<(
767 types::Package,
768 String,
769 Option<types::SharableInstallManifest>,
770 PathBuf,
771 Option<String>,
772)> {
773 let request = parse_source_string(source_str)?;
774 let resolved_source = resolve_source_recursive(source_str, 0, quiet)?;
775 let registry_handle = resolved_source.registry_handle.clone();
776 let pkg_lua_path = resolved_source.path.clone();
777
778 let pkg_template = crate::pkg::lua::parser::parse_lua_package(
779 resolved_source.path.to_str().ok_or_else(|| {
780 anyhow!(
781 "Path contains invalid UTF-8 characters: {:?}",
782 resolved_source.path
783 )
784 })?,
785 None,
786 quiet,
787 )?;
788
789 let mut pkg_with_repo = pkg_template;
790 if let Some(repo_name) = resolved_source.repo_name.clone() {
791 pkg_with_repo.repo = repo_name;
792 }
793
794 let version_string = get_version_for_install(
795 &pkg_with_repo,
796 &request.version_spec,
797 registry_handle.as_deref(),
798 )?;
799
800 let mut pkg = crate::pkg::lua::parser::parse_lua_package(
801 resolved_source.path.to_str().ok_or_else(|| {
802 anyhow!(
803 "Path contains invalid UTF-8 characters: {:?}",
804 resolved_source.path
805 )
806 })?,
807 Some(&version_string),
808 quiet,
809 )?;
810 if let Some(repo_name) = resolved_source.repo_name.clone() {
811 pkg.repo = repo_name;
812 }
813 pkg.version = Some(version_string.clone());
814
815 let registry_handle = resolved_source.registry_handle.clone();
816
817 Ok((
818 pkg,
819 version_string,
820 resolved_source.sharable_manifest,
821 pkg_lua_path,
822 registry_handle,
823 ))
824}
825
826fn resolve_source_recursive(source: &str, depth: u8, quiet: bool) -> Result<ResolvedSource> {
827 if depth > 5 {
828 return Err(anyhow!(
829 "Exceeded max resolution depth, possible circular 'alt' reference."
830 ));
831 }
832
833 if source.ends_with(".manifest.yaml") {
834 let path = PathBuf::from(source);
835 if !path.exists() {
836 return Err(anyhow!("Local file not found at '{source}'"));
837 }
838 println!("Using local sharable manifest file: {}", path.display());
839 let content = fs::read_to_string(&path)?;
840 let sharable_manifest: types::SharableInstallManifest = serde_yaml::from_str(&content)?;
841 let new_source = format!(
842 "#{}@{}/{}@{}",
843 sharable_manifest.registry_handle,
844 sharable_manifest.repo,
845 sharable_manifest.name,
846 sharable_manifest.version
847 );
848 let mut resolved_source = resolve_source_recursive(&new_source, depth + 1, quiet)?;
849 resolved_source.sharable_manifest = Some(sharable_manifest);
850 return Ok(resolved_source);
851 }
852
853 let (path_part, _sub_package) = if let Some((base, sub)) = source.rsplit_once(':') {
854 if (base.ends_with(".pkg.lua") || base.ends_with(".manifest.yaml")) && !sub.contains('/') {
855 (base, Some(sub.to_string()))
856 } else {
857 (source, None)
858 }
859 } else {
860 (source, None)
861 };
862
863 let request = parse_source_string(source)?;
864
865 if let Some(handle) = &request.handle
866 && handle.starts_with("git:")
867 {
868 let git_source = handle.strip_prefix("git:").unwrap();
869 println!(
870 "Warning: using remote git repo '{}' not from official Zoi database.",
871 git_source.yellow()
872 );
873
874 let (host, repo_path) = git_source
875 .split_once('/')
876 .ok_or_else(|| anyhow!("Invalid git source format. Expected host/owner/repo."))?;
877
878 let (base_url, branch_sep) = match host {
879 "github.com" => (
880 format!("https://raw.githubusercontent.com/{}", repo_path),
881 "/",
882 ),
883 "gitlab.com" => (format!("https://gitlab.com/{}/-/raw", repo_path), "/"),
884 "codeberg.org" => (
885 format!("https://codeberg.org/{}/raw/branch", repo_path),
886 "/",
887 ),
888 _ => return Err(anyhow!("Unsupported git host: {}", host)),
889 };
890
891 let (_, branch) = {
892 let mut last_error = None;
893 let mut content = None;
894 for b in ["main", "master"] {
895 let repo_yaml_url = format!("{}{}{}/repo.yaml", base_url, branch_sep, b);
896 match download_content_from_url(&repo_yaml_url) {
897 Ok(c) => {
898 content = Some((c, b.to_string()));
899 break;
900 }
901 Err(e) => {
902 last_error = Some(e);
903 }
904 }
905 }
906 content.ok_or_else(|| {
907 last_error
908 .unwrap_or_else(|| anyhow!("Could not find repo.yaml on main or master branch"))
909 })?
910 };
911
912 let full_pkg_path = if let Some(r) = &request.repo {
913 format!("{}/{}", r, request.name)
914 } else {
915 request.name.clone()
916 };
917
918 let pkg_name = Path::new(&full_pkg_path)
919 .file_name()
920 .ok_or_else(|| anyhow!("Invalid package path: {}", full_pkg_path))?
921 .to_str()
922 .ok_or_else(|| anyhow!("Package name contains invalid UTF-8: {}", full_pkg_path))?;
923 let pkg_lua_filename = format!("{}.pkg.lua", pkg_name);
924 let pkg_lua_path_in_repo = Path::new(&full_pkg_path).join(pkg_lua_filename);
925
926 let pkg_lua_url = format!(
927 "{}{}{}/{}",
928 base_url,
929 branch_sep,
930 branch,
931 pkg_lua_path_in_repo
932 .to_str()
933 .ok_or_else(|| anyhow!("Package path contains invalid UTF-8"))?
934 .replace('\\', "/")
935 );
936
937 let pkg_lua_content = download_content_from_url(&pkg_lua_url)?;
938
939 let temp_path = env::temp_dir().join(format!(
940 "zoi-temp-git-{}.pkg.lua",
941 Utc::now().timestamp_nanos_opt().unwrap_or(0)
942 ));
943 fs::write(&temp_path, pkg_lua_content)?;
944
945 let repo_name = format!("git:{}", git_source);
946
947 return Ok(ResolvedSource {
948 path: temp_path,
949 source_type: SourceType::GitRepo(repo_name.clone()),
950 repo_name: Some(repo_name),
951 registry_handle: None,
952 sharable_manifest: None,
953 });
954 }
955
956 let resolved_source = if source.starts_with("@git/") {
957 let full_path_str = source.trim_start_matches("@git/");
958 let parts: Vec<&str> = full_path_str.split('/').collect();
959
960 if parts.len() < 2 {
961 return Err(anyhow!(
962 "Invalid git source. Use @git/<repo-name>/<path/to/pkg>"
963 ));
964 }
965
966 let repo_name = parts[0];
967 let nested_path_parts = &parts[1..];
968 let pkg_name = nested_path_parts.last().unwrap();
969
970 let home_dir = home::home_dir().ok_or_else(|| anyhow!("Could not find home directory."))?;
971 let mut path = home_dir
972 .join(".zoi")
973 .join("pkgs")
974 .join("git")
975 .join(repo_name);
976
977 for part in nested_path_parts.iter().take(nested_path_parts.len() - 1) {
978 path = path.join(part);
979 }
980
981 path = path.join(format!("{}.pkg.lua", pkg_name));
982
983 if !path.exists() {
984 let nested_path_str = nested_path_parts.join("/");
985 return Err(anyhow!(
986 "Package '{}' not found in git repo '{}' (expected: {})",
987 nested_path_str,
988 repo_name,
989 path.display()
990 ));
991 }
992 println!(
993 "Warning: using external git repo '{}{}' not from official Zoi database.",
994 "@git/".yellow(),
995 repo_name.yellow()
996 );
997 ResolvedSource {
998 path,
999 source_type: SourceType::GitRepo(repo_name.to_string()),
1000 repo_name: Some(format!("git/{}", repo_name)),
1001 registry_handle: Some("local".to_string()),
1002 sharable_manifest: None,
1003 }
1004 } else if source.starts_with("http://") || source.starts_with("https://") {
1005 download_from_url(source)?
1006 } else if path_part.ends_with(".pkg.lua") {
1007 let path = PathBuf::from(path_part);
1008 if !path.exists() {
1009 return Err(anyhow!("Local file not found at '{path_part}'"));
1010 }
1011 ResolvedSource {
1012 path,
1013 source_type: SourceType::LocalFile,
1014 repo_name: None,
1015 registry_handle: Some("local".to_string()),
1016 sharable_manifest: None,
1017 }
1018 } else {
1019 find_package_in_db(&request, quiet)?
1020 };
1021
1022 let pkg_for_alt_check = crate::pkg::lua::parser::parse_lua_package(
1023 resolved_source.path.to_str().ok_or_else(|| {
1024 anyhow!(
1025 "Path contains invalid UTF-8 characters: {:?}",
1026 resolved_source.path
1027 )
1028 })?,
1029 None,
1030 quiet,
1031 )?;
1032
1033 if let Some(alt_source) = pkg_for_alt_check.alt {
1034 println!("Found 'alt' source. Resolving from: {}", alt_source.cyan());
1035
1036 let mut alt_resolved_source =
1037 if alt_source.starts_with("http://") || alt_source.starts_with("https://") {
1038 println!("Downloading 'alt' source from: {}", alt_source.cyan());
1039 let client = crate::utils::build_blocking_http_client(20)?;
1040 let mut attempt = 0u32;
1041 let response = loop {
1042 attempt += 1;
1043 match client.get(&alt_source).send() {
1044 Ok(resp) => break resp,
1045 Err(e) => {
1046 if attempt < 3 {
1047 eprintln!(
1048 "{}: download failed ({}). Retrying...",
1049 "Network".yellow(),
1050 e
1051 );
1052 crate::utils::retry_backoff_sleep(attempt);
1053 continue;
1054 } else {
1055 return Err(anyhow!(
1056 "Failed to download file after {} attempts: {}",
1057 attempt,
1058 e
1059 ));
1060 }
1061 }
1062 }
1063 };
1064 if !response.status().is_success() {
1065 return Err(anyhow!(
1066 "Failed to download alt source (HTTP {}): {}",
1067 response.status(),
1068 alt_source
1069 ));
1070 }
1071
1072 let content = response.text()?;
1073 let temp_path = env::temp_dir().join(format!(
1074 "zoi-alt-{}.pkg.lua",
1075 Utc::now().timestamp_nanos_opt().unwrap_or(0)
1076 ));
1077 fs::write(&temp_path, &content)?;
1078 resolve_source_recursive(
1079 temp_path.to_str().ok_or_else(|| {
1080 anyhow!(
1081 "Temporary path contains invalid UTF-8 characters: {:?}",
1082 temp_path
1083 )
1084 })?,
1085 depth + 1,
1086 quiet,
1087 )?
1088 } else {
1089 resolve_source_recursive(&alt_source, depth + 1, quiet)?
1090 };
1091
1092 if resolved_source.source_type == SourceType::OfficialRepo {
1093 alt_resolved_source.source_type = SourceType::OfficialRepo;
1094 }
1095
1096 return Ok(alt_resolved_source);
1097 }
1098
1099 Ok(resolved_source)
1100}