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