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