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