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