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