Skip to main content

zoi_resolver/
resolve.rs

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