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