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