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