Skip to main content

zoi_resolver/
resolve.rs

1//! Main dependency resolution logic.
2//!
3//! This module implements the core resolution algorithm for Zoi, handling
4//! various source types (registries, local files, URLs, Git repositories)
5//! and version specifications.
6
7use std::collections::HashMap;
8use std::fs;
9use std::io::Read;
10use std::path::{Path, PathBuf};
11
12use anyhow::{Result, anyhow};
13use colored::Colorize;
14use comfy_table::Table;
15use comfy_table::presets::UTF8_FULL;
16use dialoguer::Select;
17use dialoguer::theme::ColorfulTheme;
18use indicatif::{ProgressBar, ProgressStyle};
19use regex::Regex;
20use sha2::{Digest, Sha256};
21use walkdir::WalkDir;
22use zoi_core::types::SourceType;
23use zoi_core::{cache, config, pin, types};
24
25/// Represents a source that has been resolved to a local path.
26#[derive(Debug)]
27pub struct ResolvedSource {
28    /// The path to the resolved `.pkg.lua` or manifest file.
29    pub path: PathBuf,
30    /// The type of the source.
31    pub source_type: SourceType,
32    /// The name of the repository, if applicable.
33    pub repo_name: Option<String>,
34    /// The type of the repository (e.g. official, unofficial).
35    pub repo_type: Option<String>,
36    /// The handle of the registry.
37    pub registry_handle: Option<String>,
38    /// An optional sharable manifest associated with the source.
39    pub sharable_manifest: Option<types::SharableInstallManifest>,
40    /// The Git SHA if the source is from a Git repository.
41    pub git_sha: Option<String>
42}
43
44/// Represents a request for a package, parsed from a source string.
45#[derive(Debug, Default)]
46pub struct PackageRequest {
47    /// The registry handle (e.g. 'zoidberg').
48    pub handle: Option<String>,
49    /// The repository name (e.g. 'core').
50    pub repo: Option<String>,
51    /// The name of the package.
52    pub name: String,
53    /// The sub-package name, if any.
54    pub sub_package: Option<String>,
55    /// The version or channel specification.
56    pub version_spec: Option<String>
57}
58
59use std::sync::{LazyLock, Mutex};
60
61/// Regex for parsing the registry handle from a source string.
62static HANDLE_RE: LazyLock<Regex> = LazyLock::new(|| {
63    Regex::new(r"^(?:#(?P<handle>[^@]+))?(?P<main_part>.*)$")
64        .expect("Static HANDLE_RE regex is valid")
65});
66
67/// Regex for parsing the repository, package name, and version from a source
68/// string.
69static MAIN_RE: LazyLock<Regex> = LazyLock::new(|| {
70    Regex::new(r"^@?(?P<repo_and_name>[^@]+)(?:@(?P<version>.+))?$")
71        .expect("Static MAIN_RE regex is valid")
72});
73
74/// A set of untrusted sources that have been confirmed by the user in the
75/// current session.
76static CONFIRMED_UNTRUSTED_SOURCES: LazyLock<
77    Mutex<std::collections::HashSet<String>>
78> = LazyLock::new(|| Mutex::new(std::collections::HashSet::new()));
79
80/// Splits a source string that explicitly points to a file into its components.
81fn split_explicit_file_source(
82    source_str: &str
83) -> Option<(&str, Option<String>, Option<String>)> {
84    let (main_part, version_spec) =
85        if let Some((base, version)) = source_str.rsplit_once('@') {
86            let base_path = if let Some((path, sub)) = base.rsplit_once(':') {
87                if (path.ends_with(".pkg.lua")
88                    || path.ends_with(".manifest.yaml")
89                    || std::path::Path::new(path)
90                        .extension()
91                        .is_some_and(|ext| ext.eq_ignore_ascii_case("zpa"))
92                    || std::path::Path::new(path)
93                        .extension()
94                        .is_some_and(|ext| ext.eq_ignore_ascii_case("zsa")))
95                    && !sub.contains('/')
96                {
97                    path
98                } else {
99                    base
100                }
101            } else {
102                base
103            };
104
105            if base_path.ends_with(".pkg.lua")
106                || base_path.ends_with(".manifest.yaml")
107                || std::path::Path::new(base_path)
108                    .extension()
109                    .is_some_and(|ext| ext.eq_ignore_ascii_case("zpa"))
110                || std::path::Path::new(base_path)
111                    .extension()
112                    .is_some_and(|ext| ext.eq_ignore_ascii_case("zsa"))
113            {
114                (base, Some(version.to_string()))
115            } else {
116                (source_str, None)
117            }
118        } else {
119            (source_str, None)
120        };
121
122    let (path_part, sub_package) =
123        if let Some((base, sub)) = main_part.rsplit_once(':') {
124            if (base.ends_with(".pkg.lua")
125                || base.ends_with(".manifest.yaml")
126                || std::path::Path::new(base)
127                    .extension()
128                    .is_some_and(|ext| ext.eq_ignore_ascii_case("zpa"))
129                || std::path::Path::new(base)
130                    .extension()
131                    .is_some_and(|ext| ext.eq_ignore_ascii_case("zsa")))
132                && !sub.contains('/')
133            {
134                (base, Some(sub.to_string()))
135            } else {
136                (main_part, None)
137            }
138        } else {
139            (main_part, None)
140        };
141
142    if path_part.ends_with(".pkg.lua")
143        || path_part.ends_with(".manifest.yaml")
144        || std::path::Path::new(path_part)
145            .extension()
146            .is_some_and(|ext| ext.eq_ignore_ascii_case("zpa"))
147        || std::path::Path::new(path_part)
148            .extension()
149            .is_some_and(|ext| ext.eq_ignore_ascii_case("zsa"))
150    {
151        Some((path_part, sub_package, version_spec))
152    } else {
153        None
154    }
155}
156
157/// Returns the original source or the path part if available.
158fn download_source_for_explicit_path<'a>(
159    source: &'a str,
160    path_part: Option<&'a str>
161) -> &'a str {
162    path_part.unwrap_or(source)
163}
164
165/// Returns the HEAD SHA of a local Git repository.
166fn get_git_head_sha(repo_path: &Path) -> Option<String> {
167    let repo = git2::Repository::open(repo_path).ok()?;
168    let head = repo.head().ok()?;
169    let target = head.target()?;
170    Some(target.to_string())
171}
172
173/// Returns the root directory of the package database.
174///
175/// # Errors
176///
177/// Returns an error if the current directory cannot be retrieved.
178pub fn get_db_root() -> Result<PathBuf> {
179    if let Ok(path) = std::env::var("ZOI_DB_DIR") {
180        return Ok(PathBuf::from(path));
181    }
182
183    let local_db = std::env::current_dir()?
184        .join(".zoi")
185        .join("pkgs")
186        .join("db");
187    if local_db.exists() {
188        return Ok(local_db);
189    }
190
191    // Default to user scope for normal CLI usage
192    zoi_core::utils::get_db_base_dir(zoi_core::types::Scope::User)
193}
194
195/// Returns the root directory of the package database on the host system.
196///
197/// # Errors
198///
199/// Returns an error if the home directory cannot be found.
200pub fn get_host_db_root() -> Result<PathBuf> {
201    let home_dir = zoi_core::utils::get_user_home()
202        .ok_or_else(|| anyhow!("Could not find home directory."))?;
203    Ok(home_dir.join(".zoi").join("pkgs").join("db"))
204}
205
206/// Parses a source string into a `PackageRequest`.
207///
208/// # Errors
209///
210/// Returns an error if the source string format is invalid.
211pub fn parse_source_string(source_str: &str) -> Result<PackageRequest> {
212    if let Some((path_part, sub_package_from_path, version_spec)) =
213        split_explicit_file_source(source_str)
214    {
215        let path = std::path::Path::new(path_part);
216        let file_stem = path.file_stem().unwrap_or_default().to_string_lossy();
217        let name = if let Some(stripped) = file_stem.strip_suffix(".manifest") {
218            stripped.to_string()
219        } else if let Some(stripped) = file_stem.strip_suffix(".pkg") {
220            stripped.to_string()
221        } else {
222            file_stem.to_string()
223        };
224        return Ok(PackageRequest {
225            handle: None,
226            repo: None,
227            name,
228            sub_package: sub_package_from_path,
229            version_spec
230        });
231    }
232
233    let caps = HANDLE_RE
234        .captures(source_str)
235        .ok_or_else(|| anyhow!("Invalid source string format"))?;
236    let handle = caps.name("handle").map(|m| m.as_str().to_string());
237    let main_part = caps
238        .name("main_part")
239        .ok_or_else(|| {
240            anyhow!(
241                "Regex matched but main_part group not found in '{source_str}'"
242            )
243        })?
244        .as_str();
245
246    let caps_main = MAIN_RE.captures(main_part).ok_or_else(|| {
247        anyhow!("Invalid source string format in '{main_part}'")
248    })?;
249
250    let repo_and_name = caps_main
251        .name("repo_and_name")
252        .ok_or_else(|| {
253            anyhow!(
254                "Regex matched but repo_and_name group not found in \
255                 '{main_part}'"
256            )
257        })?
258        .as_str();
259    let version_spec =
260        caps_main.name("version").map(|m| m.as_str().to_string());
261
262    let (repo, name_and_sub) = if main_part.starts_with('@') {
263        if let Some(slash_pos) = repo_and_name.find('/') {
264            let (repo_str, name_str) = repo_and_name.split_at(slash_pos);
265            (Some(repo_str.to_lowercase()), &name_str[1..])
266        } else {
267            return Err(anyhow!("Invalid repo format: expected @repo/name"));
268        }
269    } else {
270        (None, repo_and_name)
271    };
272
273    let (name, sub_package) =
274        if let Some((n, s)) = name_and_sub.rsplit_once(':') {
275            (n, Some(s.to_string()))
276        } else {
277            (name_and_sub, None)
278        };
279
280    if name.is_empty() {
281        return Err(anyhow!("Invalid source string: package name is empty."));
282    }
283
284    Ok(PackageRequest {
285        handle,
286        repo,
287        name: name.to_lowercase(),
288        sub_package,
289        version_spec
290    })
291}
292
293/// Searches for a package in the synced package database.
294fn find_package_in_db(
295    request: &PackageRequest,
296    quiet: bool
297) -> Result<ResolvedSource> {
298    /// Internal structure to hold metadata of a found package during
299    /// resolution.
300    struct FoundPackage {
301        path: PathBuf,
302        source_type: SourceType,
303        repo_name: String,
304        repo_type: String,
305        description: String,
306        license: String,
307        size: Option<u64>
308    }
309
310    /// Processes a `.pkg.lua` file to extract its metadata.
311    fn process_found_package(
312        path: PathBuf,
313        repo_name: &str,
314        is_default_registry: bool,
315        registry_db_path: &Path,
316        quiet: bool
317    ) -> Result<FoundPackage> {
318        let pkg: types::Package = zoi_lua::parser::parse_lua_package(
319            path.to_str().ok_or_else(|| {
320                anyhow!(
321                    "Path contains invalid UTF-8 characters: {}",
322                    path.display()
323                )
324            })?,
325            None,
326            None,
327            quiet
328        )?;
329        let major_repo = repo_name
330            .split('/')
331            .next()
332            .unwrap_or_default()
333            .to_lowercase();
334
335        let repo_config = config::read_repo_config(registry_db_path).ok();
336        let repo_type = if let Some(ref cfg) = repo_config {
337            cfg.repos.iter().find(|r| r.name == major_repo).map_or_else(
338                || "unofficial".to_string(),
339                |r| r.repo_type.clone()
340            )
341        } else {
342            "unofficial".to_string()
343        };
344
345        let source_type = if is_default_registry && repo_type == "official" {
346            SourceType::OfficialRepo
347        } else {
348            SourceType::UntrustedRepo(repo_name.to_string())
349        };
350
351        Ok(FoundPackage {
352            path,
353            source_type,
354            repo_name: pkg.repo.clone(),
355            repo_type,
356            description: pkg.description,
357            license: pkg.license,
358            size: pkg.installed_size
359        })
360    }
361
362    let db_root = get_db_root()?;
363    let config = config::read_config()?;
364
365    let (registry_db_path, search_repos, is_default_registry, registry_handle) =
366        if let Some(h) = &request.handle {
367            let is_default = config
368                .default_registry
369                .as_ref()
370                .is_some_and(|reg| reg.handle == *h);
371
372            if is_default {
373                let default_registry = config
374                    .default_registry
375                    .as_ref()
376                    .ok_or_else(|| anyhow!("Default registry not found"))?;
377                (
378                    db_root.join(&default_registry.handle),
379                    config.repos,
380                    true,
381                    Some(default_registry.handle.clone())
382                )
383            } else if let Some(registry) =
384                config.added_registries.iter().find(|r| r.handle == *h)
385            {
386                let mut repo_path = db_root.join(&registry.handle);
387
388                if !repo_path.exists()
389                    && zoi_core::sysroot::get_sysroot().is_some()
390                {
391                    // Fallback to host metadata for resolution
392                    let host_root = get_host_db_root()?;
393                    let host_path = host_root.join(&registry.handle);
394                    if host_path.exists() {
395                        repo_path = host_path;
396                    }
397                }
398
399                let all_sub_repos = if repo_path.exists() {
400                    fs::read_dir(&repo_path)?
401                        .filter_map(Result::ok)
402                        .filter(|entry| {
403                            entry.path().is_dir() && entry.file_name() != ".git"
404                        })
405                        .map(|entry| {
406                            entry.file_name().to_string_lossy().into_owned()
407                        })
408                        .collect()
409                } else {
410                    Vec::new()
411                };
412                (
413                    repo_path,
414                    all_sub_repos,
415                    false,
416                    Some(registry.handle.clone())
417                )
418            } else {
419                return Err(anyhow!("Registry with handle '{h}' not found."));
420            }
421        } else {
422            let default_registry = config
423                .default_registry
424                .as_ref()
425                .ok_or_else(|| anyhow!("No default registry set."))?;
426
427            let default_handle = default_registry.handle.clone();
428            let mut default_path = db_root.join(&default_handle);
429
430            if !default_path.exists()
431                && zoi_core::sysroot::get_sysroot().is_some()
432            {
433                // Fallback to host metadata for resolution
434                let host_root = get_host_db_root()?;
435                let host_path = host_root.join(&default_handle);
436                if host_path.exists() {
437                    default_path = host_path;
438                }
439            }
440
441            let (registry_path, effective_handle) = if default_path.exists()
442                && (default_path.join("repo.yaml").exists()
443                    || default_path.join("packages.json").exists())
444            {
445                (default_path, default_handle)
446            } else {
447                let mut found_path = default_path.clone();
448                let mut found_handle = default_handle.clone();
449                let mut found = false;
450
451                let roots_to_check =
452                    if zoi_core::sysroot::get_sysroot().is_some() {
453                        vec![db_root.clone(), get_host_db_root()?]
454                    } else {
455                        vec![db_root.clone()]
456                    };
457
458                for root in roots_to_check {
459                    if let Ok(entries) = fs::read_dir(&root) {
460                        for entry in entries.flatten() {
461                            let path = entry.path();
462                            if !path.is_dir() {
463                                continue;
464                            }
465                            let name = entry.file_name();
466                            if name == ".git" {
467                                continue;
468                            }
469                            let candidate = name.to_string_lossy().to_string();
470                            let candidate_path = root.join(&candidate);
471                            if candidate_path.join("repo.yaml").exists()
472                                || candidate_path.join("packages.json").exists()
473                            {
474                                found_path = candidate_path;
475                                found_handle = candidate;
476                                found = true;
477                                break;
478                            }
479                        }
480                    }
481                    if found {
482                        break;
483                    }
484                }
485
486                if !found {
487                    return Err(anyhow!(
488                        "No synced registries found. Please run 'zoi sync' to \
489                         download the package database."
490                    ));
491                }
492                (found_path, found_handle)
493            };
494
495            (registry_path, config.repos, true, Some(effective_handle))
496        };
497
498    if !registry_db_path.exists() {
499        return Err(anyhow!(
500            "Registry '{}' is not synced. Please run 'zoi sync' to download \
501             the package database.",
502            registry_handle.unwrap_or_else(|| "default".to_string())
503        ));
504    }
505
506    let repos_to_search = if let Some(r) = &request.repo {
507        vec![r.clone()]
508    } else {
509        search_repos
510    };
511
512    let mut found_packages = Vec::new();
513
514    if request.name.contains('/') {
515        let pkg_name = Path::new(&request.name)
516            .file_name()
517            .and_then(|s| s.to_str())
518            .ok_or_else(|| anyhow!("Invalid package path: {}", request.name))?;
519
520        for repo_name in &repos_to_search {
521            let path = registry_db_path
522                .join(repo_name)
523                .join(&request.name)
524                .join(format!("{pkg_name}.pkg.lua"));
525
526            if path.exists()
527                && let Ok(found) = process_found_package(
528                    path,
529                    repo_name,
530                    is_default_registry,
531                    &registry_db_path,
532                    quiet
533                )
534            {
535                found_packages.push(found);
536            }
537        }
538    } else {
539        for repo_name in &repos_to_search {
540            let pkg_dir_path =
541                registry_db_path.join(repo_name).join(&request.name);
542            let pkg_file_path =
543                pkg_dir_path.join(format!("{}.pkg.lua", request.name));
544
545            if pkg_file_path.exists()
546                && let Ok(found) = process_found_package(
547                    pkg_file_path,
548                    repo_name,
549                    is_default_registry,
550                    &registry_db_path,
551                    quiet
552                )
553            {
554                found_packages.push(found);
555            }
556        }
557    }
558
559    if found_packages.is_empty() {
560        for repo_name in &repos_to_search {
561            let repo_path = registry_db_path.join(repo_name);
562            if !repo_path.is_dir() {
563                continue;
564            }
565            for entry in WalkDir::new(&repo_path)
566                .into_iter()
567                .filter_map(std::result::Result::ok)
568                .filter(|e| {
569                    e.file_type().is_file()
570                        && e.file_name().to_string_lossy().ends_with(".pkg.lua")
571                })
572            {
573                if let Ok(pkg) = zoi_lua::parser::parse_lua_package(
574                    entry.path().to_str().ok_or_else(|| {
575                        anyhow!(
576                            "Path contains invalid UTF-8 characters: {}",
577                            entry.path().display()
578                        )
579                    })?,
580                    None,
581                    None,
582                    true
583                ) && let Some(provides) = &pkg.provides
584                    && provides.iter().any(|p| p == &request.name)
585                {
586                    let major_repo = repo_name
587                        .split('/')
588                        .next()
589                        .unwrap_or_default()
590                        .to_lowercase();
591                    let repo_config =
592                        config::read_repo_config(&registry_db_path).ok();
593                    let repo_type = if let Some(ref cfg) = repo_config {
594                        cfg.repos
595                            .iter()
596                            .find(|r| r.name == major_repo)
597                            .map_or_else(
598                                || "unofficial".to_string(),
599                                |r| r.repo_type.clone()
600                            )
601                    } else {
602                        "unofficial".to_string()
603                    };
604                    let source_type =
605                        if is_default_registry && repo_type == "official" {
606                            SourceType::OfficialRepo
607                        } else {
608                            SourceType::UntrustedRepo(repo_name.clone())
609                        };
610                    found_packages.push(FoundPackage {
611                        path: entry.path().to_path_buf(),
612                        source_type,
613                        repo_name: pkg.repo.clone(),
614                        repo_type,
615                        description: pkg.description,
616                        license: pkg.license,
617                        size: pkg.installed_size
618                    });
619                }
620            }
621        }
622    }
623
624    if found_packages.is_empty() {
625        if let Some(repo) = &request.repo {
626            Err(anyhow!(
627                "Package '{}' not found in repository '@{}'.",
628                request.name,
629                repo
630            ))
631        } else {
632            Err(anyhow!(
633                "Package '{}' not found in any active repositories.",
634                request.name
635            ))
636        }
637    } else if found_packages.len() == 1 {
638        let chosen = found_packages.first().ok_or_else(|| {
639            anyhow!("Found packages list is unexpectedly empty")
640        })?;
641
642        Ok(ResolvedSource {
643            path: chosen.path.clone(),
644            source_type: chosen.source_type.clone(),
645            repo_name: Some(chosen.repo_name.clone()),
646            repo_type: Some(chosen.repo_type.clone()),
647            registry_handle: registry_handle.clone(),
648            sharable_manifest: None,
649            git_sha: None
650        })
651    } else {
652        println!(
653            "Found multiple packages named or providing '{}'. Please choose \
654             one:",
655            request.name.cyan()
656        );
657
658        let mut table = Table::new();
659        table.load_style(UTF8_FULL);
660        table.set_header(vec!["#", "Repo", "License", "Size", "Description"]);
661
662        for (i, p) in found_packages.iter().enumerate() {
663            table.add_row(vec![
664                (i + 1).to_string(),
665                p.repo_name.clone(),
666                p.license.clone(),
667                p.size.map_or_else(
668                    || "unknown".to_string(),
669                    zoi_core::utils::format_bytes
670                ),
671                p.description.clone(),
672            ]);
673        }
674        println!("{table}");
675
676        let items: Vec<String> = found_packages
677            .iter()
678            .map(|p| format!("@{}", p.repo_name.bold()))
679            .collect();
680
681        let selection = Select::with_theme(&ColorfulTheme::default())
682            .with_prompt("Select a provider")
683            .items(&items)
684            .default(0)
685            .interact()?;
686
687        let chosen = found_packages
688            .get(selection)
689            .ok_or_else(|| anyhow!("Invalid selection"))?;
690        println!(
691            "Selected package '{}' from repo '{}'",
692            request.name, chosen.repo_name
693        );
694
695        Ok(ResolvedSource {
696            path: chosen.path.clone(),
697            source_type: chosen.source_type.clone(),
698            repo_name: Some(chosen.repo_name.clone()),
699            repo_type: Some(chosen.repo_type.clone()),
700            registry_handle: registry_handle.clone(),
701            sharable_manifest: None,
702            git_sha: None
703        })
704    }
705}
706
707/// Downloads a package definition from a URL and caches it locally.
708fn download_from_url(url: &str) -> Result<ResolvedSource> {
709    let (base_url, expected_hash) = if let Some((base, hash_part)) =
710        url.split_once('#')
711    {
712        if hash_part.starts_with("sha256-") || hash_part.starts_with("sha512-")
713        {
714            (base, Some(hash_part))
715        } else {
716            (url, None)
717        }
718    } else {
719        (url, None)
720    };
721
722    let cache_dir = cache::get_pkgdef_cache_root()?;
723    fs::create_dir_all(&cache_dir)?;
724
725    let mut hasher = Sha256::new();
726    hasher.update(base_url.as_bytes());
727    let url_hash = hex::encode(hasher.finalize());
728    let cache_path = cache_dir.join(format!("{url_hash}.pkg.lua"));
729
730    if cache_path.exists() {
731        if let Some(hash) = expected_hash {
732            let mut file = fs::File::open(&cache_path)?;
733            let mut content = Vec::new();
734            file.read_to_end(&mut content)?;
735            if verify_content_hash(&content, hash)? {
736                return Ok(ResolvedSource {
737                    path: cache_path,
738                    source_type: SourceType::Url,
739                    repo_name: None,
740                    repo_type: None,
741                    registry_handle: Some("local".to_string()),
742                    sharable_manifest: None,
743                    git_sha: None
744                });
745            }
746            println!("Cached definition hash mismatch, re-downloading...");
747            fs::remove_file(&cache_path)?;
748        } else {
749            return Ok(ResolvedSource {
750                path: cache_path,
751                source_type: SourceType::Url,
752                repo_name: None,
753                repo_type: None,
754                registry_handle: Some("local".to_string()),
755                sharable_manifest: None,
756                git_sha: None
757            });
758        }
759    }
760
761    println!("Downloading package definition from URL...");
762    let client = zoi_core::utils::get_http_client()?;
763    let mut attempt = 0u32;
764    let mut response = loop {
765        attempt += 1;
766        match client.get(base_url).send() {
767            Ok(resp) => break resp,
768            Err(e) => {
769                if attempt < 3 {
770                    eprintln!(
771                        "{}: download failed ({}). Retrying...",
772                        "Network".yellow(),
773                        e
774                    );
775                    zoi_core::utils::retry_backoff_sleep(attempt);
776                    continue;
777                }
778                return Err(anyhow!(
779                    "Failed to download file after {attempt} attempts: {e}"
780                ));
781            }
782        }
783    };
784    if !response.status().is_success() {
785        return Err(anyhow!(
786            "Failed to download file (HTTP {}): {}",
787            response.status(),
788            base_url
789        ));
790    }
791
792    let total_size = response.content_length().unwrap_or(0);
793    let pb = ProgressBar::new(total_size);
794    pb.set_style(
795        ProgressStyle::default_bar()
796            .template(
797                "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] \
798                 {bytes}/{total_bytes} ({bytes_per_sec})"
799            )?
800            .progress_chars("#>-")
801    );
802
803    let mut downloaded_bytes = Vec::new();
804    let mut buffer = [0; 8192];
805    loop {
806        let bytes_read = response.read(&mut buffer)?;
807        if bytes_read == 0 {
808            break;
809        }
810        downloaded_bytes.extend_from_slice(
811            buffer
812                .get(..bytes_read)
813                .ok_or_else(|| anyhow!("Buffer slice out of bounds"))?
814        );
815        pb.inc(bytes_read as u64);
816    }
817    pb.finish_with_message("Download complete.");
818
819    if let Some(hash) = expected_hash {
820        if !verify_content_hash(&downloaded_bytes, hash)? {
821            return Err(anyhow!(
822                "Integrity verification failed for remote package definition."
823            ));
824        }
825        println!("{} Integrity verified.", "::".green());
826    }
827
828    fs::write(&cache_path, &downloaded_bytes)?;
829
830    Ok(ResolvedSource {
831        path: cache_path,
832        source_type: SourceType::Url,
833        repo_name: None,
834        repo_type: None,
835        registry_handle: Some("local".to_string()),
836        sharable_manifest: None,
837        git_sha: None
838    })
839}
840
841/// Verifies the hash of the given content against a hash specification.
842fn verify_content_hash(content: &[u8], hash_spec: &str) -> Result<bool> {
843    let (algo, expected_hex) = hash_spec
844        .split_once('-')
845        .ok_or_else(|| anyhow!("Invalid hash format"))?;
846    let actual_hex = match algo {
847        "sha256" => {
848            let mut hasher = Sha256::new();
849            hasher.update(content);
850            hex::encode(hasher.finalize())
851        }
852        "sha512" => {
853            let mut hasher = sha2::Sha512::new();
854            hasher.update(content);
855            hex::encode(hasher.finalize())
856        }
857        _ => return Err(anyhow!("Unsupported hash algorithm: {algo}"))
858    };
859
860    Ok(actual_hex.eq_ignore_ascii_case(expected_hex))
861}
862
863/// Downloads content from a URL as a string.
864fn download_content_from_url(url: &str) -> Result<String> {
865    println!("Downloading from: {}", url.cyan());
866    let client = zoi_core::utils::get_http_client()?;
867    let mut attempt = 0u32;
868    let response = loop {
869        attempt += 1;
870        match client.get(url).send() {
871            Ok(resp) => break resp,
872            Err(e) => {
873                if attempt < 3 {
874                    eprintln!(
875                        "{}: download failed ({}). Retrying...",
876                        "Network".yellow(),
877                        e
878                    );
879                    zoi_core::utils::retry_backoff_sleep(attempt);
880                    continue;
881                }
882                return Err(anyhow!(
883                    "Failed to download from {url} after {attempt} attempts: \
884                     {e}"
885                ));
886            }
887        }
888    };
889
890    if !response.status().is_success() {
891        return Err(anyhow!(
892            "Failed to download from {} (HTTP {}). Content: {}",
893            url,
894            response.status(),
895            response
896                .text()
897                .unwrap_or_else(|_| "Could not read response body".to_string())
898        ));
899    }
900
901    Ok(response.text()?)
902}
903
904/// Resolves a version from a JSON URL for a given channel.
905///
906/// # Errors
907///
908/// Returns an error if the request fails or the response cannot be parsed.
909pub fn resolve_version_from_url(url: &str, channel: &str) -> Result<String> {
910    println!(
911        "Resolving version for channel '{}' from {}",
912        channel.cyan(),
913        url.cyan()
914    );
915    let client = zoi_core::utils::get_http_client()?;
916    let mut attempt = 0u32;
917    let resp = loop {
918        attempt += 1;
919        match client.get(url).send() {
920            Ok(r) => match r.text() {
921                Ok(t) => break t,
922                Err(e) => {
923                    if attempt < 3 {
924                        eprintln!(
925                            "{}: read failed ({}). Retrying...",
926                            "Network".yellow(),
927                            e
928                        );
929                        zoi_core::utils::retry_backoff_sleep(attempt);
930                        continue;
931                    }
932                    return Err(anyhow!(
933                        "Failed to read response after {attempt} attempts: {e}"
934                    ));
935                }
936            },
937            Err(e) => {
938                if attempt < 3 {
939                    eprintln!(
940                        "{}: fetch failed ({}). Retrying...",
941                        "Network".yellow(),
942                        e
943                    );
944                    zoi_core::utils::retry_backoff_sleep(attempt);
945                    continue;
946                }
947                return Err(anyhow!(
948                    "Failed to fetch after {attempt} attempts: {e}"
949                ));
950            }
951        }
952    };
953    let json: serde_json::Value = serde_json::from_str(&resp)?;
954
955    if let Some(version) = json
956        .get("versions")
957        .and_then(|v| v.get(channel))
958        .and_then(|c| c.as_str())
959    {
960        return Ok(version.to_string());
961    }
962
963    Err(anyhow!(
964        "Failed to extract version for channel '{channel}' from JSON URL: \
965         {url}"
966    ))
967}
968
969/// Resolves a channel name to a concrete version.
970///
971/// # Errors
972///
973/// Returns an error if the channel name is not found in the versions map.
974pub fn resolve_channel<S: ::std::hash::BuildHasher>(
975    versions: &HashMap<String, String, S>,
976    channel: &str
977) -> Result<String> {
978    if let Some(url_or_version) = versions.get(channel) {
979        if url_or_version.starts_with("http") {
980            resolve_version_from_url(url_or_version, channel)
981        } else {
982            Ok(url_or_version.clone())
983        }
984    } else {
985        Err(anyhow!("Channel '@{channel}' not found in versions map."))
986    }
987}
988
989/// Returns the default version for a package.
990///
991/// # Errors
992///
993/// Returns an error if the version cannot be determined.
994pub fn get_default_version(
995    pkg: &types::Package,
996    registry_handle: Option<&str>
997) -> Result<String> {
998    if let Some(handle) = registry_handle {
999        let source = format!("#{}@{}", handle, pkg.repo);
1000
1001        if let Some(pinned_version) = pin::get_pinned_version(&source)? {
1002            println!(
1003                "Using pinned version '{}' for {}.",
1004                pinned_version.yellow(),
1005                source.cyan()
1006            );
1007            return if pinned_version.starts_with('@') {
1008                let channel = pinned_version.trim_start_matches('@');
1009                let versions = pkg.versions.as_ref().ok_or_else(|| {
1010                    anyhow!(
1011                        "Package '{}' has no 'versions' map to resolve pinned \
1012                         channel '{}'.",
1013                        pkg.name,
1014                        pinned_version
1015                    )
1016                })?;
1017                resolve_channel(versions, channel)
1018            } else {
1019                Ok(pinned_version)
1020            };
1021        }
1022    }
1023
1024    if let Some(versions) = &pkg.versions {
1025        if versions.contains_key("stable") {
1026            return resolve_channel(versions, "stable");
1027        }
1028        let mut channels: Vec<_> = versions.keys().collect();
1029        channels.sort();
1030        if let Some(channel) = channels.first() {
1031            println!(
1032                "No 'stable' channel found, using first available channel: \
1033                 '@{}'",
1034                channel.cyan()
1035            );
1036            return resolve_channel(versions, channel);
1037        }
1038        return Err(anyhow!(
1039            "Package has a 'versions' map but no versions were found in it."
1040        ));
1041    }
1042
1043    if let Some(ver) = &pkg.version {
1044        if ver.starts_with("http") {
1045            let client = zoi_core::utils::get_http_client()?;
1046            let mut attempt = 0u32;
1047            let resp = loop {
1048                attempt += 1;
1049                match client.get(ver).send() {
1050                    Ok(r) => match r.text() {
1051                        Ok(t) => break t,
1052                        Err(e) => {
1053                            if attempt < 3 {
1054                                eprintln!(
1055                                    "{}: read failed ({}). Retrying...",
1056                                    "Network".yellow(),
1057                                    e
1058                                );
1059                                zoi_core::utils::retry_backoff_sleep(attempt);
1060                                continue;
1061                            }
1062                            return Err(anyhow!(
1063                                "Failed to read response after {attempt} \
1064                                 attempts: {e}"
1065                            ));
1066                        }
1067                    },
1068                    Err(e) => {
1069                        if attempt < 3 {
1070                            eprintln!(
1071                                "{}: fetch failed ({}). Retrying...",
1072                                "Network".yellow(),
1073                                e
1074                            );
1075                            zoi_core::utils::retry_backoff_sleep(attempt);
1076                            continue;
1077                        }
1078                        return Err(anyhow!(
1079                            "Failed to fetch after {attempt} attempts: {e}"
1080                        ));
1081                    }
1082                }
1083            };
1084            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&resp) {
1085                if let Some(version) = json
1086                    .get("versions")
1087                    .and_then(|v| v.get("stable"))
1088                    .and_then(|s| s.as_str())
1089                {
1090                    return Ok(version.to_string());
1091                }
1092
1093                if let Some(tag) = json
1094                    .get("latest")
1095                    .and_then(|l| l.get("production"))
1096                    .and_then(|p| p.get("tag"))
1097                    .and_then(|t| t.as_str())
1098                {
1099                    return Ok(tag.to_string());
1100                }
1101                return Err(anyhow!(
1102                    "Could not determine a version from the JSON content at \
1103                     {ver}"
1104                ));
1105            }
1106            return Ok(resp.trim().to_string());
1107        }
1108        return Ok(ver.clone());
1109    }
1110
1111    Err(anyhow!(
1112        "Could not determine a version for package '{}'.",
1113        pkg.name
1114    ))
1115}
1116
1117/// Returns the specific version to install based on a version specification.
1118fn get_version_for_install(
1119    pkg: &types::Package,
1120    version_spec: Option<&String>,
1121    registry_handle: Option<&str>
1122) -> Result<String> {
1123    if let Some(spec) = version_spec {
1124        if spec.starts_with('@') {
1125            let channel = spec.trim_start_matches('@');
1126            let versions = pkg.versions.as_ref().ok_or_else(|| {
1127                anyhow!(
1128                    "Package '{}' has no 'versions' map to resolve channel \
1129                     '@{}'.",
1130                    pkg.name,
1131                    channel
1132                )
1133            })?;
1134            return resolve_channel(versions, channel);
1135        }
1136
1137        if let Some(versions) = &pkg.versions
1138            && versions.contains_key(spec)
1139        {
1140            println!("Found '{}' as a channel, resolving...", spec.cyan());
1141            return resolve_channel(versions, spec);
1142        }
1143
1144        return Ok(spec.clone());
1145    }
1146
1147    get_default_version(pkg, registry_handle)
1148}
1149
1150/// Resolves the requested version specification from a source string.
1151///
1152/// # Errors
1153///
1154/// Returns an error if the source string format is invalid or if resolution
1155/// fails.
1156pub fn resolve_requested_version_spec(
1157    source_str: &str,
1158    scope: Option<types::Scope>,
1159    quiet: bool,
1160    yes: bool
1161) -> Result<Option<String>> {
1162    let request = parse_source_string(source_str)?;
1163    let Some(_) = request.version_spec else {
1164        return Ok(None);
1165    };
1166
1167    let resolved_source = resolve_source(source_str, scope, quiet, yes)?;
1168    let mut pkg = zoi_lua::parser::parse_lua_package(
1169        resolved_source.path.to_str().ok_or_else(|| {
1170            anyhow!(
1171                "Path contains invalid UTF-8 characters: {}",
1172                resolved_source.path.display()
1173            )
1174        })?,
1175        None,
1176        scope,
1177        quiet
1178    )?;
1179
1180    if let Some(repo_name) = resolved_source.repo_name {
1181        pkg.repo = repo_name;
1182    }
1183
1184    get_version_for_install(
1185        &pkg,
1186        request.version_spec.as_ref(),
1187        resolved_source.registry_handle.as_deref()
1188    )
1189    .map(Some)
1190}
1191
1192/// Resolves a source identifier into a concrete local path to a `.pkg.lua`
1193/// file.
1194///
1195/// This is the primary entry point for package discovery. It handles:
1196/// - Parsing the source string (e.g. #reg@repo/name@version).
1197/// - Deciding if the source is a local file, a URL, or a registry-backed
1198///   package.
1199/// - Recursively following 'alt' references if the package definition points
1200///   elsewhere.
1201/// - Confirming trust for untrusted sources (URLs/local files).
1202///
1203/// # Errors
1204///
1205/// Returns an error if resolution fails or if an untrusted source is rejected.
1206pub fn resolve_source(
1207    source: &str,
1208    scope: Option<types::Scope>,
1209    quiet: bool,
1210    yes: bool
1211) -> Result<ResolvedSource> {
1212    let config = config::read_config().unwrap_or_default();
1213    let max_depth = config.max_resolution_depth.unwrap_or(7);
1214    let resolved =
1215        resolve_source_recursive(source, 0, max_depth, scope, quiet)?;
1216
1217    if !quiet {
1218        let confirmation_key = match &resolved.source_type {
1219            SourceType::LocalFile => Some(
1220                resolved
1221                    .path
1222                    .canonicalize()
1223                    .unwrap_or_else(|_| resolved.path.clone())
1224                    .to_string_lossy()
1225                    .to_string()
1226            ),
1227            SourceType::Url => Some(source.to_string()),
1228            _ => None
1229        };
1230
1231        let confirmation_key = if let Some(key) = confirmation_key {
1232            let confirmed =
1233                CONFIRMED_UNTRUSTED_SOURCES.lock().map_err(|e| {
1234                    anyhow!("Failed to lock trust confirmation cache: {e}")
1235                })?;
1236            if confirmed.contains(&key) {
1237                None
1238            } else {
1239                Some(key)
1240            }
1241        } else {
1242            None
1243        };
1244
1245        if let Some(key) = confirmation_key {
1246            zoi_core::utils::confirm_untrusted_source(
1247                &resolved.source_type,
1248                yes
1249            )?;
1250            let mut confirmed =
1251                CONFIRMED_UNTRUSTED_SOURCES.lock().map_err(|e| {
1252                    anyhow!("Failed to lock trust confirmation cache: {e}")
1253                })?;
1254            confirmed.insert(key);
1255        }
1256    }
1257
1258    if let Ok(_request) = parse_source_string(source)
1259        && !matches!(
1260            &resolved.source_type,
1261            SourceType::LocalFile | SourceType::Url
1262        )
1263        && let Some(_repo_name) = &resolved.repo_name
1264    {}
1265
1266    Ok(resolved)
1267}
1268
1269/// Resolves a package and its version from a source string.
1270///
1271/// # Errors
1272///
1273/// Returns an error if parsing or resolution fails.
1274pub fn resolve_package_and_version(
1275    source_str: &str,
1276    scope: Option<types::Scope>,
1277    quiet: bool,
1278    yes: bool
1279) -> Result<(
1280    types::Package,
1281    String,
1282    Option<types::SharableInstallManifest>,
1283    PathBuf,
1284    Option<String>,
1285    Option<String>,
1286    Option<String>
1287)> {
1288    let request = parse_source_string(source_str)?;
1289    let resolved_source = resolve_source(source_str, scope, quiet, yes)?;
1290    let registry_handle = resolved_source.registry_handle.clone();
1291    let repo_type = resolved_source.repo_type.clone();
1292    let pkg_lua_path = resolved_source.path.clone();
1293    let git_sha = resolved_source.git_sha.clone();
1294
1295    let pkg_template = zoi_lua::parser::parse_lua_package(
1296        resolved_source.path.to_str().ok_or_else(|| {
1297            anyhow!(
1298                "Path contains invalid UTF-8 characters: {}",
1299                resolved_source.path.display()
1300            )
1301        })?,
1302        None,
1303        scope,
1304        quiet
1305    )?;
1306
1307    let mut pkg_with_repo = pkg_template;
1308    if let Some(repo_name) = resolved_source.repo_name.clone() {
1309        pkg_with_repo.repo = repo_name;
1310    }
1311
1312    let version_string = get_version_for_install(
1313        &pkg_with_repo,
1314        request.version_spec.as_ref(),
1315        registry_handle.as_deref()
1316    )?;
1317
1318    let mut pkg = zoi_lua::parser::parse_lua_package(
1319        resolved_source.path.to_str().ok_or_else(|| {
1320            anyhow!(
1321                "Path contains invalid UTF-8 characters: {}",
1322                resolved_source.path.display()
1323            )
1324        })?,
1325        Some(&version_string),
1326        scope,
1327        quiet
1328    )?;
1329    if let Some(repo_name) = resolved_source.repo_name.clone() {
1330        pkg.repo = repo_name;
1331    }
1332    pkg.version = Some(version_string.clone());
1333
1334    let registry_handle = resolved_source.registry_handle.clone();
1335
1336    Ok((
1337        pkg,
1338        version_string,
1339        resolved_source.sharable_manifest,
1340        pkg_lua_path,
1341        registry_handle,
1342        repo_type,
1343        git_sha
1344    ))
1345}
1346
1347/// Recursively resolves a source identifier.
1348fn resolve_source_recursive(
1349    source: &str,
1350    depth: u8,
1351    max_depth: u8,
1352    scope: Option<types::Scope>,
1353    quiet: bool
1354) -> Result<ResolvedSource> {
1355    if max_depth > 0 && depth > max_depth {
1356        let msg = format!(
1357            "Resolution depth {depth} exceeds limit {max_depth}. Potential \
1358             circular 'alt' reference."
1359        );
1360        if quiet
1361            || !zoi_core::utils::ask_for_confirmation(
1362                &format!("{msg} Continue anyway?"),
1363                false
1364            )
1365        {
1366            return Err(anyhow!("Exceeded max resolution depth."));
1367        }
1368    }
1369
1370    if source.ends_with(".manifest.yaml") {
1371        let path = PathBuf::from(source);
1372        if !path.exists() {
1373            return Err(anyhow!("Local file not found at '{source}'"));
1374        }
1375        println!("Using local sharable manifest file: {}", path.display());
1376        let content = fs::read_to_string(&path)?;
1377        let sharable_manifest: types::SharableInstallManifest =
1378            serde_yaml::from_str(&content)?;
1379        let new_source = format!(
1380            "#{}@{}/{}@{}",
1381            sharable_manifest.registry_handle,
1382            sharable_manifest.repo,
1383            sharable_manifest.name,
1384            sharable_manifest.version
1385        );
1386        let mut resolved_source = resolve_source_recursive(
1387            &new_source,
1388            depth + 1,
1389            max_depth,
1390            scope,
1391            quiet
1392        )?;
1393        resolved_source.sharable_manifest = Some(sharable_manifest);
1394        return Ok(resolved_source);
1395    }
1396
1397    let path_part = split_explicit_file_source(source).map(|(path, _, _)| path);
1398
1399    let request = parse_source_string(source)?;
1400
1401    if let Some(handle) = &request.handle
1402        && handle.starts_with("git:")
1403    {
1404        if zoi_core::offline::is_offline() {
1405            return Err(anyhow!(
1406                "Cannot resolve remote git repo '{handle}': Zoi is in offline \
1407                 mode."
1408            ));
1409        }
1410        let git_source = handle.strip_prefix("git:").ok_or_else(|| {
1411            anyhow!("Handle '{handle}' unexpectedly missing 'git:' prefix")
1412        })?;
1413        println!(
1414            "Warning: using remote git repo '{}' not from official Zoi \
1415             database.",
1416            git_source.yellow()
1417        );
1418
1419        let (host, repo_path) =
1420            git_source.split_once('/').ok_or_else(|| {
1421                anyhow!("Invalid git source format. Expected host/owner/repo.")
1422            })?;
1423
1424        let (base_url, branch_sep) = match host {
1425            "github.com" => (
1426                format!("https://raw.githubusercontent.com/{repo_path}"),
1427                "/"
1428            ),
1429            "gitlab.com" => {
1430                (format!("https://gitlab.com/{repo_path}/-/raw"), "/")
1431            }
1432            "codeberg.org" => {
1433                (format!("https://codeberg.org/{repo_path}/raw/branch"), "/")
1434            }
1435            _ => return Err(anyhow!("Unsupported git host: {host}"))
1436        };
1437
1438        let (_, branch) = {
1439            let mut last_error = None;
1440            let mut content = None;
1441            for b in ["main", "master"] {
1442                let repo_yaml_url =
1443                    format!("{base_url}{branch_sep}{b}/repo.yaml");
1444                match download_content_from_url(&repo_yaml_url) {
1445                    Ok(c) => {
1446                        content = Some((c, b.to_string()));
1447                        break;
1448                    }
1449                    Err(e) => {
1450                        last_error = Some(e);
1451                    }
1452                }
1453            }
1454            content.ok_or_else(|| {
1455                last_error.unwrap_or_else(|| {
1456                    anyhow!("Could not find repo.yaml on main or master branch")
1457                })
1458            })?
1459        };
1460
1461        let full_pkg_path = if let Some(r) = &request.repo {
1462            format!("{}/{}", r, request.name)
1463        } else {
1464            request.name.clone()
1465        };
1466
1467        let pkg_name = Path::new(&full_pkg_path)
1468            .file_name()
1469            .ok_or_else(|| anyhow!("Invalid package path: {full_pkg_path}"))?
1470            .to_str()
1471            .ok_or_else(|| {
1472                anyhow!("Package name contains invalid UTF-8: {full_pkg_path}")
1473            })?;
1474        let pkg_lua_filename = format!("{pkg_name}.pkg.lua");
1475        let pkg_lua_path_in_repo =
1476            Path::new(&full_pkg_path).join(pkg_lua_filename);
1477
1478        let pkg_lua_url = format!(
1479            "{}{}{}/{}",
1480            base_url,
1481            branch_sep,
1482            branch,
1483            pkg_lua_path_in_repo
1484                .to_str()
1485                .ok_or_else(|| anyhow!("Package path contains invalid UTF-8"))?
1486                .replace('\\', "/")
1487        );
1488
1489        let pkg_lua_content = download_content_from_url(&pkg_lua_url)?;
1490
1491        let cache_dir = cache::get_pkgdef_cache_root()?;
1492        fs::create_dir_all(&cache_dir)?;
1493
1494        let mut hasher = Sha256::new();
1495        hasher.update(pkg_lua_url.as_bytes());
1496        let hash = hex::encode(hasher.finalize());
1497        let cache_path = cache_dir.join(format!("{hash}.pkg.lua"));
1498
1499        fs::write(&cache_path, pkg_lua_content.as_bytes())?;
1500
1501        let repo_name = format!("git:{git_source}");
1502
1503        return Ok(ResolvedSource {
1504            path: cache_path,
1505            source_type: SourceType::GitRepo(repo_name.clone()),
1506            repo_name: Some(repo_name),
1507            repo_type: Some("unofficial".to_string()),
1508            registry_handle: None,
1509            sharable_manifest: None,
1510            git_sha: None
1511        });
1512    }
1513
1514    let resolved_source = if source.starts_with("#git@") {
1515        let full_path_str = source.trim_start_matches("#git@");
1516        let parts: Vec<&str> = full_path_str.split('/').collect();
1517
1518        if parts.len() < 2 {
1519            return Err(anyhow!(
1520                "Invalid git source. Use #git@<repo-name>/<path/to/pkg>"
1521            ));
1522        }
1523
1524        let repo_name =
1525            parts.first().ok_or_else(|| anyhow!("Invalid git source"))?;
1526        let nested_path_parts = parts
1527            .get(1..)
1528            .ok_or_else(|| anyhow!("Invalid git source path"))?;
1529        let pkg_name = nested_path_parts
1530            .last()
1531            .ok_or_else(|| anyhow!("Empty path in git source"))?;
1532
1533        let home_dir = zoi_core::utils::get_user_home()
1534            .ok_or_else(|| anyhow!("Could not find home directory."))?;
1535        let mut path = home_dir
1536            .join(".zoi")
1537            .join("pkgs")
1538            .join("git")
1539            .join(repo_name);
1540
1541        for part in nested_path_parts.iter().take(nested_path_parts.len() - 1) {
1542            path = path.join(part);
1543        }
1544
1545        path = path.join(format!("{pkg_name}.pkg.lua"));
1546
1547        if !path.exists() {
1548            let nested_path_str = nested_path_parts.join("/");
1549            return Err(anyhow!(
1550                "Package '{}' not found in git repo '{}' (expected: {})",
1551                nested_path_str,
1552                repo_name,
1553                path.display()
1554            ));
1555        }
1556        println!(
1557            "Warning: using external git repo '{}{}' not from official Zoi \
1558             database.",
1559            "#git@".yellow(),
1560            repo_name.yellow()
1561        );
1562        let git_repo_root = home_dir
1563            .join(".zoi")
1564            .join("pkgs")
1565            .join("git")
1566            .join(repo_name);
1567        let git_sha = get_git_head_sha(&git_repo_root);
1568
1569        ResolvedSource {
1570            path,
1571            source_type: SourceType::GitRepo(repo_name.to_string()),
1572            repo_name: Some(format!("git/{repo_name}")),
1573            repo_type: Some("unofficial".to_string()),
1574            registry_handle: Some("local".to_string()),
1575            sharable_manifest: None,
1576            git_sha
1577        }
1578    } else if source.starts_with("http://") || source.starts_with("https://") {
1579        if zoi_core::offline::is_offline() {
1580            return Err(anyhow!(
1581                "Cannot download package definition from URL '{source}': Zoi \
1582                 is in offline mode."
1583            ));
1584        }
1585        download_from_url(download_source_for_explicit_path(source, path_part))?
1586    } else if let Some(path_part) = path_part {
1587        let path = zoi_core::utils::expand_tilde(path_part);
1588        if !path.exists() {
1589            return Err(anyhow!(
1590                "Local file not found at '{}'",
1591                path.display()
1592            ));
1593        }
1594        ResolvedSource {
1595            path,
1596            source_type: SourceType::LocalFile,
1597            repo_name: None,
1598            repo_type: None,
1599            registry_handle: Some("local".to_string()),
1600            sharable_manifest: None,
1601            git_sha: None
1602        }
1603    } else if zoi_core::utils::is_mini_mode() {
1604        let index = crate::mini_resolve::fetch_registry_index()?;
1605
1606        let (repo, repo_type) = if let Some(r) = &request.repo {
1607            let r_type = index
1608                .packages
1609                .get(&request.name)
1610                .filter(|p| &p.repo == r)
1611                .map_or_else(
1612                    || "unofficial".to_string(),
1613                    |p| p.repo_type.clone()
1614                );
1615            (r.clone(), r_type)
1616        } else {
1617            let pkg_info =
1618                index.packages.get(&request.name).ok_or_else(|| {
1619                    anyhow!(
1620                        "Package '{}' not found in Zoidberg registry index",
1621                        request.name
1622                    )
1623                })?;
1624            (pkg_info.repo.clone(), pkg_info.repo_type.clone())
1625        };
1626
1627        let lua_url =
1628            crate::mini_resolve::get_package_lua_url(&repo, &request.name);
1629        let mut resolved = download_from_url(&lua_url)?;
1630        resolved.repo_name = Some(repo.clone());
1631        resolved.repo_type = Some(repo_type.clone());
1632        resolved.registry_handle = Some("zoidberg".to_string());
1633
1634        resolved.source_type = if repo_type == "official" {
1635            SourceType::OfficialRepo
1636        } else {
1637            SourceType::UntrustedRepo(repo)
1638        };
1639        resolved
1640    } else {
1641        find_package_in_db(&request, quiet)?
1642    };
1643
1644    let pkg_for_alt_check = zoi_lua::parser::parse_lua_package(
1645        resolved_source.path.to_str().ok_or_else(|| {
1646            anyhow!(
1647                "Path contains invalid UTF-8 characters: {}",
1648                resolved_source.path.display()
1649            )
1650        })?,
1651        None,
1652        scope,
1653        quiet
1654    )?;
1655
1656    if let Some(alt_source) = pkg_for_alt_check.alt {
1657        println!("Found 'alt' source. Resolving from: {}", alt_source.cyan());
1658
1659        let alt_resolved_source = if alt_source.starts_with("http://")
1660            || alt_source.starts_with("https://")
1661        {
1662            println!("Downloading 'alt' source from: {}", alt_source.cyan());
1663            let client = zoi_core::utils::get_http_client()?;
1664            let mut attempt = 0u32;
1665            let response = loop {
1666                attempt += 1;
1667                match client.get(&alt_source).send() {
1668                    Ok(resp) => break resp,
1669                    Err(e) => {
1670                        if attempt < 3 {
1671                            eprintln!(
1672                                "{}: download failed ({}). Retrying...",
1673                                "Network".yellow(),
1674                                e
1675                            );
1676                            zoi_core::utils::retry_backoff_sleep(attempt);
1677                            continue;
1678                        }
1679                        return Err(anyhow!(
1680                            "Failed to download file after {attempt} \
1681                             attempts: {e}"
1682                        ));
1683                    }
1684                }
1685            };
1686            if !response.status().is_success() {
1687                return Err(anyhow!(
1688                    "Failed to download alt source (HTTP {}): {}",
1689                    response.status(),
1690                    alt_source
1691                ));
1692            }
1693
1694            let content = response.text()?;
1695
1696            let cache_dir = cache::get_pkgdef_cache_root()?;
1697            fs::create_dir_all(&cache_dir)?;
1698
1699            let mut hasher = Sha256::new();
1700            hasher.update(alt_source.as_bytes());
1701            let hash = hex::encode(hasher.finalize());
1702            let cache_path = cache_dir.join(format!("{hash}.pkg.lua"));
1703
1704            fs::write(&cache_path, content.as_bytes())?;
1705
1706            resolve_source_recursive(
1707                cache_path.to_str().ok_or_else(|| {
1708                    anyhow!(
1709                        "Cache path contains invalid UTF-8 characters: {}",
1710                        cache_path.display()
1711                    )
1712                })?,
1713                depth + 1,
1714                max_depth,
1715                scope,
1716                quiet
1717            )?
1718        } else {
1719            resolve_source_recursive(
1720                &alt_source,
1721                depth + 1,
1722                max_depth,
1723                scope,
1724                quiet
1725            )?
1726        };
1727
1728        return Ok(alt_resolved_source);
1729    }
1730
1731    Ok(resolved_source)
1732}
1733
1734#[cfg(test)]
1735mod tests {
1736    use super::download_source_for_explicit_path;
1737
1738    #[test]
1739    fn test_download_source_for_explicit_http_channel_uses_base_url() {
1740        let source = "http://127.0.0.1:8000/test.pkg.lua@stable";
1741        let path_part = Some("http://127.0.0.1:8000/test.pkg.lua");
1742        assert_eq!(
1743            download_source_for_explicit_path(source, path_part),
1744            "http://127.0.0.1:8000/test.pkg.lua"
1745        );
1746    }
1747
1748    #[test]
1749    fn test_download_source_for_plain_http_source_uses_original() {
1750        let source = "http://127.0.0.1:8000/test.pkg.lua";
1751        assert_eq!(
1752            download_source_for_explicit_path(source, None),
1753            "http://127.0.0.1:8000/test.pkg.lua"
1754        );
1755    }
1756}