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