zoi/pkg/
resolve.rs

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(&registry.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(&registry_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(&registry_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}