zoi/pkg/
resolve.rs

1use crate::pkg::{config, pin, types};
2use anyhow::{Result, anyhow};
3use chrono::Utc;
4use colored::*;
5use dialoguer::{Select, theme::ColorfulTheme};
6use indicatif::{ProgressBar, ProgressStyle};
7use regex::Regex;
8use std::collections::HashMap;
9use std::env;
10use std::fs;
11use std::io::Read;
12use std::path::{Path, PathBuf};
13use walkdir::WalkDir;
14
15#[derive(Debug, PartialEq, Eq, Clone)]
16pub enum SourceType {
17    OfficialRepo,
18    UntrustedRepo(String),
19    GitRepo(String),
20    LocalFile,
21    Url,
22}
23
24#[derive(Debug)]
25pub struct ResolvedSource {
26    pub path: PathBuf,
27    pub source_type: SourceType,
28    pub repo_name: Option<String>,
29    pub registry_handle: Option<String>,
30    pub sharable_manifest: Option<types::SharableInstallManifest>,
31}
32
33#[derive(Debug, Default)]
34pub struct PackageRequest {
35    pub handle: Option<String>,
36    pub repo: Option<String>,
37    pub name: String,
38    pub sub_package: Option<String>,
39    pub version_spec: Option<String>,
40}
41
42pub fn get_db_root() -> Result<PathBuf> {
43    let home_dir = home::home_dir().ok_or_else(|| anyhow!("Could not find home directory."))?;
44    Ok(home_dir.join(".zoi").join("pkgs").join("db"))
45}
46
47pub fn parse_source_string(source_str: &str) -> Result<PackageRequest> {
48    let (path_part, sub_package_from_path) = if let Some((base, sub)) = source_str.rsplit_once(':')
49    {
50        if (base.ends_with(".pkg.lua") || base.ends_with(".manifest.yaml")) && !sub.contains('/') {
51            (base, Some(sub.to_string()))
52        } else {
53            (source_str, None)
54        }
55    } else {
56        (source_str, None)
57    };
58
59    if path_part.contains('/')
60        && (path_part.ends_with(".manifest.yaml") || path_part.ends_with(".pkg.lua"))
61    {
62        let path = std::path::Path::new(path_part);
63        let file_stem = path.file_stem().unwrap_or_default().to_string_lossy();
64        let name = if let Some(stripped) = file_stem.strip_suffix(".manifest") {
65            stripped.to_string()
66        } else if let Some(stripped) = file_stem.strip_suffix(".pkg") {
67            stripped.to_string()
68        } else {
69            file_stem.to_string()
70        };
71        return Ok(PackageRequest {
72            handle: None,
73            repo: None,
74            name,
75            sub_package: sub_package_from_path,
76            version_spec: None,
77        });
78    }
79
80    let re = Regex::new(r"^(?:#(?P<handle>[^@]+))?(?P<main_part>.*)$").unwrap();
81
82    let caps = re
83        .captures(source_str)
84        .ok_or_else(|| anyhow!("Invalid source string format"))?;
85    let handle = caps.name("handle").map(|m| m.as_str().to_string());
86    let main_part = caps.name("main_part").unwrap().as_str();
87
88    let re_main = Regex::new(r"^@?(?P<repo_and_name>[^@]+)(?:@(?P<version>.+))?$").unwrap();
89    let caps_main = re_main
90        .captures(main_part)
91        .ok_or_else(|| anyhow!("Invalid source string format"))?;
92
93    let repo_and_name = caps_main.name("repo_and_name").unwrap().as_str();
94    let version_spec = caps_main.name("version").map(|m| m.as_str().to_string());
95
96    let (repo, name_and_sub) = if main_part.starts_with('@') {
97        if let Some(slash_pos) = repo_and_name.find('/') {
98            let (repo_str, name_str) = repo_and_name.split_at(slash_pos);
99            (Some(repo_str.to_lowercase()), &name_str[1..])
100        } else {
101            return Err(anyhow!("Invalid repo format: expected @repo/name"));
102        }
103    } else {
104        (None, repo_and_name)
105    };
106
107    let (name, sub_package) = if let Some((n, s)) = name_and_sub.rsplit_once(':') {
108        (n, Some(s.to_string()))
109    } else {
110        (name_and_sub, None)
111    };
112
113    if name.is_empty() {
114        return Err(anyhow!("Invalid source string: package name is empty."));
115    }
116
117    Ok(PackageRequest {
118        handle,
119        repo,
120        name: name.to_lowercase(),
121        sub_package,
122        version_spec,
123    })
124}
125
126fn find_package_in_db(request: &PackageRequest, quiet: bool) -> Result<ResolvedSource> {
127    let db_root = get_db_root()?;
128    let config = config::read_config()?;
129
130    let (registry_db_path, search_repos, is_default_registry, registry_handle) =
131        if let Some(h) = &request.handle {
132            let is_default = config
133                .default_registry
134                .as_ref()
135                .is_some_and(|reg| reg.handle == *h);
136
137            if is_default {
138                let default_registry = config
139                    .default_registry
140                    .as_ref()
141                    .ok_or_else(|| anyhow!("Default registry not found"))?;
142                (
143                    db_root.join(&default_registry.handle),
144                    config.repos,
145                    true,
146                    Some(default_registry.handle.clone()),
147                )
148            } else if let Some(registry) = config.added_registries.iter().find(|r| r.handle == *h) {
149                let repo_path = db_root.join(&registry.handle);
150                let all_sub_repos = if repo_path.exists() {
151                    fs::read_dir(&repo_path)?
152                        .filter_map(Result::ok)
153                        .filter(|entry| entry.path().is_dir() && entry.file_name() != ".git")
154                        .map(|entry| entry.file_name().to_string_lossy().into_owned())
155                        .collect()
156                } else {
157                    Vec::new()
158                };
159                (
160                    repo_path,
161                    all_sub_repos,
162                    false,
163                    Some(registry.handle.clone()),
164                )
165            } else {
166                return Err(anyhow!("Registry with handle '{}' not found.", h));
167            }
168        } else {
169            let default_registry = config
170                .default_registry
171                .as_ref()
172                .ok_or_else(|| anyhow!("No default registry set."))?;
173            (
174                db_root.join(&default_registry.handle),
175                config.repos,
176                true,
177                Some(default_registry.handle.clone()),
178            )
179        };
180
181    let repos_to_search = if let Some(r) = &request.repo {
182        vec![r.clone()]
183    } else {
184        search_repos
185    };
186
187    struct FoundPackage {
188        path: PathBuf,
189        source_type: SourceType,
190        repo_name: String,
191        description: String,
192    }
193
194    fn process_found_package(
195        path: PathBuf,
196        repo_name: &str,
197        is_default_registry: bool,
198        registry_db_path: &Path,
199        quiet: bool,
200    ) -> Result<FoundPackage> {
201        let pkg: types::Package = crate::pkg::lua::parser::parse_lua_package(
202            path.to_str()
203                .ok_or_else(|| anyhow!("Path contains invalid UTF-8 characters: {:?}", path))?,
204            None,
205            quiet,
206        )?;
207        let major_repo = repo_name
208            .split('/')
209            .next()
210            .unwrap_or_default()
211            .to_lowercase();
212
213        let source_type = if is_default_registry {
214            let repo_config = config::read_repo_config(registry_db_path).ok();
215            if let Some(ref cfg) = repo_config {
216                if let Some(repo_entry) = cfg.repos.iter().find(|r| r.name == major_repo) {
217                    if repo_entry.repo_type == "offical" {
218                        SourceType::OfficialRepo
219                    } else {
220                        SourceType::UntrustedRepo(repo_name.to_string())
221                    }
222                } else {
223                    SourceType::UntrustedRepo(repo_name.to_string())
224                }
225            } else {
226                SourceType::UntrustedRepo(repo_name.to_string())
227            }
228        } else {
229            SourceType::UntrustedRepo(repo_name.to_string())
230        };
231
232        Ok(FoundPackage {
233            path,
234            source_type,
235            repo_name: pkg.repo.clone(),
236            description: pkg.description,
237        })
238    }
239
240    let mut found_packages = Vec::new();
241
242    if request.name.contains('/') {
243        let pkg_name = Path::new(&request.name)
244            .file_name()
245            .and_then(|s| s.to_str())
246            .ok_or_else(|| anyhow!("Invalid package path: {}", request.name))?;
247
248        for repo_name in &repos_to_search {
249            let path = registry_db_path
250                .join(repo_name)
251                .join(&request.name)
252                .join(format!("{}.pkg.lua", pkg_name));
253
254            if path.exists()
255                && let Ok(found) = process_found_package(
256                    path,
257                    repo_name,
258                    is_default_registry,
259                    &registry_db_path,
260                    quiet,
261                )
262            {
263                found_packages.push(found);
264            }
265        }
266    } else {
267        for repo_name in &repos_to_search {
268            let repo_path = registry_db_path.join(repo_name);
269            if !repo_path.is_dir() {
270                continue;
271            }
272            for entry in WalkDir::new(&repo_path)
273                .into_iter()
274                .filter_map(|e| e.ok())
275                .filter(|e| e.file_type().is_dir() && e.file_name() == request.name.as_str())
276            {
277                let pkg_dir_path = entry.path();
278
279                if let Ok(relative_path) = pkg_dir_path.strip_prefix(&repo_path) {
280                    if relative_path.components().count() > 1 {
281                        continue;
282                    }
283                } else {
284                    continue;
285                }
286
287                let pkg_file_path = pkg_dir_path.join(format!("{}.pkg.lua", request.name));
288
289                if pkg_file_path.exists()
290                    && let Ok(found) = process_found_package(
291                        pkg_file_path,
292                        repo_name,
293                        is_default_registry,
294                        &registry_db_path,
295                        quiet,
296                    )
297                {
298                    found_packages.push(found);
299                }
300            }
301        }
302    }
303
304    if found_packages.is_empty() {
305        for repo_name in &repos_to_search {
306            let repo_path = registry_db_path.join(repo_name);
307            if !repo_path.is_dir() {
308                continue;
309            }
310            for entry in WalkDir::new(&repo_path)
311                .into_iter()
312                .filter_map(|e| e.ok())
313                .filter(|e| {
314                    e.file_type().is_file() && e.file_name().to_string_lossy().ends_with(".pkg.lua")
315                })
316            {
317                if let Ok(pkg) = crate::pkg::lua::parser::parse_lua_package(
318                    entry.path().to_str().ok_or_else(|| {
319                        anyhow!("Path contains invalid UTF-8 characters: {:?}", entry.path())
320                    })?,
321                    None,
322                    true,
323                ) && let Some(provides) = &pkg.provides
324                    && provides.iter().any(|p| p == &request.name)
325                    && let Some(provides) = &pkg.provides
326                    && provides.iter().any(|p| p == &request.name)
327                {
328                    let major_repo = repo_name
329                        .split('/')
330                        .next()
331                        .unwrap_or_default()
332                        .to_lowercase();
333                    let source_type = if is_default_registry {
334                        let repo_config = config::read_repo_config(&registry_db_path).ok();
335                        if let Some(ref cfg) = repo_config {
336                            if let Some(repo_entry) =
337                                cfg.repos.iter().find(|r| r.name == major_repo)
338                            {
339                                if repo_entry.repo_type == "offical" {
340                                    SourceType::OfficialRepo
341                                } else {
342                                    SourceType::UntrustedRepo(repo_name.clone())
343                                }
344                            } else {
345                                SourceType::UntrustedRepo(repo_name.clone())
346                            }
347                        } else {
348                            SourceType::UntrustedRepo(repo_name.clone())
349                        }
350                    } else {
351                        SourceType::UntrustedRepo(repo_name.clone())
352                    };
353                    found_packages.push(FoundPackage {
354                        path: entry.path().to_path_buf(),
355                        source_type,
356                        repo_name: pkg.repo.clone(),
357                        description: pkg.description,
358                    });
359                }
360            }
361        }
362    }
363
364    if found_packages.is_empty() {
365        if let Some(repo) = &request.repo {
366            Err(anyhow!(
367                "Package '{}' not found in repository '@{}'.",
368                request.name,
369                repo
370            ))
371        } else {
372            Err(anyhow!(
373                "Package '{}' not found in any active repositories.",
374                request.name
375            ))
376        }
377    } else if found_packages.len() == 1 {
378        let chosen = &found_packages[0];
379
380        Ok(ResolvedSource {
381            path: chosen.path.clone(),
382            source_type: chosen.source_type.clone(),
383            repo_name: Some(chosen.repo_name.clone()),
384            registry_handle: registry_handle.clone(),
385            sharable_manifest: None,
386        })
387    } else {
388        println!(
389            "Found multiple packages named '{}'. Please choose one:",
390            request.name.cyan()
391        );
392
393        let items: Vec<String> = found_packages
394            .iter()
395            .map(|p| format!("@{} - {}", p.repo_name.bold(), p.description))
396            .collect();
397
398        let selection = Select::with_theme(&ColorfulTheme::default())
399            .with_prompt("Select a package")
400            .items(&items)
401            .default(0)
402            .interact()?;
403
404        let chosen = &found_packages[selection];
405        println!(
406            "Selected package '{}' from repo '{}'",
407            request.name, chosen.repo_name
408        );
409
410        Ok(ResolvedSource {
411            path: chosen.path.clone(),
412            source_type: chosen.source_type.clone(),
413            repo_name: Some(chosen.repo_name.clone()),
414            registry_handle: registry_handle.clone(),
415            sharable_manifest: None,
416        })
417    }
418}
419
420fn download_from_url(url: &str) -> Result<ResolvedSource> {
421    println!("Downloading package definition from URL...");
422    let client = crate::utils::build_blocking_http_client(20)?;
423    let mut attempt = 0u32;
424    let mut response = loop {
425        attempt += 1;
426        match client.get(url).send() {
427            Ok(resp) => break resp,
428            Err(e) => {
429                if attempt < 3 {
430                    eprintln!(
431                        "{}: download failed ({}). Retrying...",
432                        "Network".yellow(),
433                        e
434                    );
435                    crate::utils::retry_backoff_sleep(attempt);
436                    continue;
437                } else {
438                    return Err(anyhow!(
439                        "Failed to download file after {} attempts: {}",
440                        attempt,
441                        e
442                    ));
443                }
444            }
445        }
446    };
447    if !response.status().is_success() {
448        return Err(anyhow!(
449            "Failed to download file (HTTP {}): {}",
450            response.status(),
451            url
452        ));
453    }
454
455    let total_size = response.content_length().unwrap_or(0);
456    let pb = ProgressBar::new(total_size);
457    pb.set_style(ProgressStyle::default_bar()
458        .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec})")?
459        .progress_chars("#>-"));
460
461    let mut downloaded_bytes = Vec::new();
462    let mut buffer = [0; 8192];
463    loop {
464        let bytes_read = response.read(&mut buffer)?;
465        if bytes_read == 0 {
466            break;
467        }
468        downloaded_bytes.extend_from_slice(&buffer[..bytes_read]);
469        pb.inc(bytes_read as u64);
470    }
471    pb.finish_with_message("Download complete.");
472
473    let content = String::from_utf8(downloaded_bytes)?;
474
475    let temp_path = env::temp_dir().join(format!(
476        "zoi-temp-{}.pkg.lua",
477        Utc::now().timestamp_nanos_opt().unwrap_or(0)
478    ));
479    fs::write(&temp_path, content)?;
480
481    Ok(ResolvedSource {
482        path: temp_path,
483        source_type: SourceType::Url,
484        repo_name: None,
485        registry_handle: Some("local".to_string()),
486        sharable_manifest: None,
487    })
488}
489
490fn download_content_from_url(url: &str) -> Result<String> {
491    println!("Downloading from: {}", url.cyan());
492    let client = crate::utils::build_blocking_http_client(20)?;
493    let mut attempt = 0u32;
494    let response = loop {
495        attempt += 1;
496        match client.get(url).send() {
497            Ok(resp) => break resp,
498            Err(e) => {
499                if attempt < 3 {
500                    eprintln!(
501                        "{}: download failed ({}). Retrying...",
502                        "Network".yellow(),
503                        e
504                    );
505                    crate::utils::retry_backoff_sleep(attempt);
506                    continue;
507                } else {
508                    return Err(anyhow!(
509                        "Failed to download from {} after {} attempts: {}",
510                        url,
511                        attempt,
512                        e
513                    ));
514                }
515            }
516        }
517    };
518
519    if !response.status().is_success() {
520        return Err(anyhow!(
521            "Failed to download from {} (HTTP {}). Content: {}",
522            url,
523            response.status(),
524            response
525                .text()
526                .unwrap_or_else(|_| "Could not read response body".to_string())
527        ));
528    }
529
530    Ok(response.text()?)
531}
532
533fn resolve_version_from_url(url: &str, channel: &str) -> Result<String> {
534    println!(
535        "Resolving version for channel '{}' from {}",
536        channel.cyan(),
537        url.cyan()
538    );
539    let client = crate::utils::build_blocking_http_client(15)?;
540    let mut attempt = 0u32;
541    let resp = loop {
542        attempt += 1;
543        match client.get(url).send() {
544            Ok(r) => match r.text() {
545                Ok(t) => break t,
546                Err(e) => {
547                    if attempt < 3 {
548                        eprintln!("{}: read failed ({}). Retrying...", "Network".yellow(), e);
549                        crate::utils::retry_backoff_sleep(attempt);
550                        continue;
551                    } else {
552                        return Err(anyhow!(
553                            "Failed to read response after {} attempts: {}",
554                            attempt,
555                            e
556                        ));
557                    }
558                }
559            },
560            Err(e) => {
561                if attempt < 3 {
562                    eprintln!("{}: fetch failed ({}). Retrying...", "Network".yellow(), e);
563                    crate::utils::retry_backoff_sleep(attempt);
564                    continue;
565                } else {
566                    return Err(anyhow!("Failed to fetch after {} attempts: {}", attempt, e));
567                }
568            }
569        }
570    };
571    let json: serde_json::Value = serde_json::from_str(&resp)?;
572
573    if let Some(version) = json
574        .get("versions")
575        .and_then(|v| v.get(channel))
576        .and_then(|c| c.as_str())
577    {
578        return Ok(version.to_string());
579    }
580
581    Err(anyhow!(
582        "Failed to extract version for channel '{channel}' from JSON URL: {url}"
583    ))
584}
585
586fn resolve_channel(versions: &HashMap<String, String>, channel: &str) -> Result<String> {
587    if let Some(url_or_version) = versions.get(channel) {
588        if url_or_version.starts_with("http") {
589            resolve_version_from_url(url_or_version, channel)
590        } else {
591            Ok(url_or_version.clone())
592        }
593    } else {
594        Err(anyhow!("Channel '@{}' not found in versions map.", channel))
595    }
596}
597
598pub fn get_default_version(pkg: &types::Package, registry_handle: Option<&str>) -> Result<String> {
599    if let Some(handle) = registry_handle {
600        let source = format!("#{}@{}", handle, pkg.repo);
601
602        if let Some(pinned_version) = pin::get_pinned_version(&source)? {
603            println!(
604                "Using pinned version '{}' for {}.",
605                pinned_version.yellow(),
606                source.cyan()
607            );
608            return if pinned_version.starts_with('@') {
609                let channel = pinned_version.trim_start_matches('@');
610                let versions = pkg.versions.as_ref().ok_or_else(|| {
611                    anyhow!(
612                        "Package '{}' has no 'versions' map to resolve pinned channel '{}'.",
613                        pkg.name,
614                        pinned_version
615                    )
616                })?;
617                resolve_channel(versions, channel)
618            } else {
619                Ok(pinned_version)
620            };
621        }
622    }
623
624    if let Some(versions) = &pkg.versions {
625        if versions.contains_key("stable") {
626            return resolve_channel(versions, "stable");
627        }
628        if let Some((channel, _)) = versions.iter().next() {
629            println!(
630                "No 'stable' channel found, using first available channel: '@{}'",
631                channel.cyan()
632            );
633            return resolve_channel(versions, channel);
634        }
635        return Err(anyhow!(
636            "Package has a 'versions' map but no versions were found in it."
637        ));
638    }
639
640    if let Some(ver) = &pkg.version {
641        if ver.starts_with("http") {
642            let client = crate::utils::build_blocking_http_client(15)?;
643            let mut attempt = 0u32;
644            let resp = loop {
645                attempt += 1;
646                match client.get(ver).send() {
647                    Ok(r) => match r.text() {
648                        Ok(t) => break t,
649                        Err(e) => {
650                            if attempt < 3 {
651                                eprintln!(
652                                    "{}: read failed ({}). Retrying...",
653                                    "Network".yellow(),
654                                    e
655                                );
656                                crate::utils::retry_backoff_sleep(attempt);
657                                continue;
658                            } else {
659                                return Err(anyhow!(
660                                    "Failed to read response after {} attempts: {}",
661                                    attempt,
662                                    e
663                                ));
664                            }
665                        }
666                    },
667                    Err(e) => {
668                        if attempt < 3 {
669                            eprintln!("{}: fetch failed ({}). Retrying...", "Network".yellow(), e);
670                            crate::utils::retry_backoff_sleep(attempt);
671                            continue;
672                        } else {
673                            return Err(anyhow!(
674                                "Failed to fetch after {} attempts: {}",
675                                attempt,
676                                e
677                            ));
678                        }
679                    }
680                }
681            };
682            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&resp) {
683                if let Some(version) = json
684                    .get("versions")
685                    .and_then(|v| v.get("stable"))
686                    .and_then(|s| s.as_str())
687                {
688                    return Ok(version.to_string());
689                }
690
691                if let Some(tag) = json
692                    .get("latest")
693                    .and_then(|l| l.get("production"))
694                    .and_then(|p| p.get("tag"))
695                    .and_then(|t| t.as_str())
696                {
697                    return Ok(tag.to_string());
698                }
699                return Err(anyhow!(
700                    "Could not determine a version from the JSON content at {}",
701                    ver
702                ));
703            }
704            return Ok(resp.trim().to_string());
705        } else {
706            return Ok(ver.clone());
707        }
708    }
709
710    Err(anyhow!(
711        "Could not determine a version for package '{}'.",
712        pkg.name
713    ))
714}
715
716fn get_version_for_install(
717    pkg: &types::Package,
718    version_spec: &Option<String>,
719    registry_handle: Option<&str>,
720) -> Result<String> {
721    if let Some(spec) = version_spec {
722        if spec.starts_with('@') {
723            let channel = spec.trim_start_matches('@');
724            let versions = pkg.versions.as_ref().ok_or_else(|| {
725                anyhow!(
726                    "Package '{}' has no 'versions' map to resolve channel '@{}'.",
727                    pkg.name,
728                    channel
729                )
730            })?;
731            return resolve_channel(versions, channel);
732        }
733
734        if let Some(versions) = &pkg.versions
735            && versions.contains_key(spec)
736        {
737            println!("Found '{}' as a channel, resolving...", spec.cyan());
738            return resolve_channel(versions, spec);
739        }
740
741        return Ok(spec.clone());
742    }
743
744    get_default_version(pkg, registry_handle)
745}
746
747pub fn resolve_source(source: &str, quiet: bool) -> Result<ResolvedSource> {
748    let resolved = resolve_source_recursive(source, 0, quiet)?;
749
750    if let Ok(request) = parse_source_string(source)
751        && !matches!(
752            &resolved.source_type,
753            SourceType::LocalFile | SourceType::Url
754        )
755        && let Some(repo_name) = &resolved.repo_name
756    {
757        println!("Found package '{}' in repo '{}'", request.name, repo_name);
758    }
759
760    Ok(resolved)
761}
762
763pub fn resolve_package_and_version(
764    source_str: &str,
765    quiet: bool,
766) -> Result<(
767    types::Package,
768    String,
769    Option<types::SharableInstallManifest>,
770    PathBuf,
771    Option<String>,
772)> {
773    let request = parse_source_string(source_str)?;
774    let resolved_source = resolve_source_recursive(source_str, 0, quiet)?;
775    let registry_handle = resolved_source.registry_handle.clone();
776    let pkg_lua_path = resolved_source.path.clone();
777
778    let pkg_template = crate::pkg::lua::parser::parse_lua_package(
779        resolved_source.path.to_str().ok_or_else(|| {
780            anyhow!(
781                "Path contains invalid UTF-8 characters: {:?}",
782                resolved_source.path
783            )
784        })?,
785        None,
786        quiet,
787    )?;
788
789    let mut pkg_with_repo = pkg_template;
790    if let Some(repo_name) = resolved_source.repo_name.clone() {
791        pkg_with_repo.repo = repo_name;
792    }
793
794    let version_string = get_version_for_install(
795        &pkg_with_repo,
796        &request.version_spec,
797        registry_handle.as_deref(),
798    )?;
799
800    let mut pkg = crate::pkg::lua::parser::parse_lua_package(
801        resolved_source.path.to_str().ok_or_else(|| {
802            anyhow!(
803                "Path contains invalid UTF-8 characters: {:?}",
804                resolved_source.path
805            )
806        })?,
807        Some(&version_string),
808        quiet,
809    )?;
810    if let Some(repo_name) = resolved_source.repo_name.clone() {
811        pkg.repo = repo_name;
812    }
813    pkg.version = Some(version_string.clone());
814
815    let registry_handle = resolved_source.registry_handle.clone();
816
817    Ok((
818        pkg,
819        version_string,
820        resolved_source.sharable_manifest,
821        pkg_lua_path,
822        registry_handle,
823    ))
824}
825
826fn resolve_source_recursive(source: &str, depth: u8, quiet: bool) -> Result<ResolvedSource> {
827    if depth > 5 {
828        return Err(anyhow!(
829            "Exceeded max resolution depth, possible circular 'alt' reference."
830        ));
831    }
832
833    if source.ends_with(".manifest.yaml") {
834        let path = PathBuf::from(source);
835        if !path.exists() {
836            return Err(anyhow!("Local file not found at '{source}'"));
837        }
838        println!("Using local sharable manifest file: {}", path.display());
839        let content = fs::read_to_string(&path)?;
840        let sharable_manifest: types::SharableInstallManifest = serde_yaml::from_str(&content)?;
841        let new_source = format!(
842            "#{}@{}/{}@{}",
843            sharable_manifest.registry_handle,
844            sharable_manifest.repo,
845            sharable_manifest.name,
846            sharable_manifest.version
847        );
848        let mut resolved_source = resolve_source_recursive(&new_source, depth + 1, quiet)?;
849        resolved_source.sharable_manifest = Some(sharable_manifest);
850        return Ok(resolved_source);
851    }
852
853    let (path_part, _sub_package) = if let Some((base, sub)) = source.rsplit_once(':') {
854        if (base.ends_with(".pkg.lua") || base.ends_with(".manifest.yaml")) && !sub.contains('/') {
855            (base, Some(sub.to_string()))
856        } else {
857            (source, None)
858        }
859    } else {
860        (source, None)
861    };
862
863    let request = parse_source_string(source)?;
864
865    if let Some(handle) = &request.handle
866        && handle.starts_with("git:")
867    {
868        let git_source = handle.strip_prefix("git:").unwrap();
869        println!(
870            "Warning: using remote git repo '{}' not from official Zoi database.",
871            git_source.yellow()
872        );
873
874        let (host, repo_path) = git_source
875            .split_once('/')
876            .ok_or_else(|| anyhow!("Invalid git source format. Expected host/owner/repo."))?;
877
878        let (base_url, branch_sep) = match host {
879            "github.com" => (
880                format!("https://raw.githubusercontent.com/{}", repo_path),
881                "/",
882            ),
883            "gitlab.com" => (format!("https://gitlab.com/{}/-/raw", repo_path), "/"),
884            "codeberg.org" => (
885                format!("https://codeberg.org/{}/raw/branch", repo_path),
886                "/",
887            ),
888            _ => return Err(anyhow!("Unsupported git host: {}", host)),
889        };
890
891        let (_, branch) = {
892            let mut last_error = None;
893            let mut content = None;
894            for b in ["main", "master"] {
895                let repo_yaml_url = format!("{}{}{}/repo.yaml", base_url, branch_sep, b);
896                match download_content_from_url(&repo_yaml_url) {
897                    Ok(c) => {
898                        content = Some((c, b.to_string()));
899                        break;
900                    }
901                    Err(e) => {
902                        last_error = Some(e);
903                    }
904                }
905            }
906            content.ok_or_else(|| {
907                last_error
908                    .unwrap_or_else(|| anyhow!("Could not find repo.yaml on main or master branch"))
909            })?
910        };
911
912        let full_pkg_path = if let Some(r) = &request.repo {
913            format!("{}/{}", r, request.name)
914        } else {
915            request.name.clone()
916        };
917
918        let pkg_name = Path::new(&full_pkg_path)
919            .file_name()
920            .ok_or_else(|| anyhow!("Invalid package path: {}", full_pkg_path))?
921            .to_str()
922            .ok_or_else(|| anyhow!("Package name contains invalid UTF-8: {}", full_pkg_path))?;
923        let pkg_lua_filename = format!("{}.pkg.lua", pkg_name);
924        let pkg_lua_path_in_repo = Path::new(&full_pkg_path).join(pkg_lua_filename);
925
926        let pkg_lua_url = format!(
927            "{}{}{}/{}",
928            base_url,
929            branch_sep,
930            branch,
931            pkg_lua_path_in_repo
932                .to_str()
933                .ok_or_else(|| anyhow!("Package path contains invalid UTF-8"))?
934                .replace('\\', "/")
935        );
936
937        let pkg_lua_content = download_content_from_url(&pkg_lua_url)?;
938
939        let temp_path = env::temp_dir().join(format!(
940            "zoi-temp-git-{}.pkg.lua",
941            Utc::now().timestamp_nanos_opt().unwrap_or(0)
942        ));
943        fs::write(&temp_path, pkg_lua_content)?;
944
945        let repo_name = format!("git:{}", git_source);
946
947        return Ok(ResolvedSource {
948            path: temp_path,
949            source_type: SourceType::GitRepo(repo_name.clone()),
950            repo_name: Some(repo_name),
951            registry_handle: None,
952            sharable_manifest: None,
953        });
954    }
955
956    let resolved_source = if source.starts_with("@git/") {
957        let full_path_str = source.trim_start_matches("@git/");
958        let parts: Vec<&str> = full_path_str.split('/').collect();
959
960        if parts.len() < 2 {
961            return Err(anyhow!(
962                "Invalid git source. Use @git/<repo-name>/<path/to/pkg>"
963            ));
964        }
965
966        let repo_name = parts[0];
967        let nested_path_parts = &parts[1..];
968        let pkg_name = nested_path_parts.last().unwrap();
969
970        let home_dir = home::home_dir().ok_or_else(|| anyhow!("Could not find home directory."))?;
971        let mut path = home_dir
972            .join(".zoi")
973            .join("pkgs")
974            .join("git")
975            .join(repo_name);
976
977        for part in nested_path_parts.iter().take(nested_path_parts.len() - 1) {
978            path = path.join(part);
979        }
980
981        path = path.join(format!("{}.pkg.lua", pkg_name));
982
983        if !path.exists() {
984            let nested_path_str = nested_path_parts.join("/");
985            return Err(anyhow!(
986                "Package '{}' not found in git repo '{}' (expected: {})",
987                nested_path_str,
988                repo_name,
989                path.display()
990            ));
991        }
992        println!(
993            "Warning: using external git repo '{}{}' not from official Zoi database.",
994            "@git/".yellow(),
995            repo_name.yellow()
996        );
997        ResolvedSource {
998            path,
999            source_type: SourceType::GitRepo(repo_name.to_string()),
1000            repo_name: Some(format!("git/{}", repo_name)),
1001            registry_handle: Some("local".to_string()),
1002            sharable_manifest: None,
1003        }
1004    } else if source.starts_with("http://") || source.starts_with("https://") {
1005        download_from_url(source)?
1006    } else if path_part.ends_with(".pkg.lua") {
1007        let path = PathBuf::from(path_part);
1008        if !path.exists() {
1009            return Err(anyhow!("Local file not found at '{path_part}'"));
1010        }
1011        ResolvedSource {
1012            path,
1013            source_type: SourceType::LocalFile,
1014            repo_name: None,
1015            registry_handle: Some("local".to_string()),
1016            sharable_manifest: None,
1017        }
1018    } else {
1019        find_package_in_db(&request, quiet)?
1020    };
1021
1022    let pkg_for_alt_check = crate::pkg::lua::parser::parse_lua_package(
1023        resolved_source.path.to_str().ok_or_else(|| {
1024            anyhow!(
1025                "Path contains invalid UTF-8 characters: {:?}",
1026                resolved_source.path
1027            )
1028        })?,
1029        None,
1030        quiet,
1031    )?;
1032
1033    if let Some(alt_source) = pkg_for_alt_check.alt {
1034        println!("Found 'alt' source. Resolving from: {}", alt_source.cyan());
1035
1036        let mut alt_resolved_source =
1037            if alt_source.starts_with("http://") || alt_source.starts_with("https://") {
1038                println!("Downloading 'alt' source from: {}", alt_source.cyan());
1039                let client = crate::utils::build_blocking_http_client(20)?;
1040                let mut attempt = 0u32;
1041                let response = loop {
1042                    attempt += 1;
1043                    match client.get(&alt_source).send() {
1044                        Ok(resp) => break resp,
1045                        Err(e) => {
1046                            if attempt < 3 {
1047                                eprintln!(
1048                                    "{}: download failed ({}). Retrying...",
1049                                    "Network".yellow(),
1050                                    e
1051                                );
1052                                crate::utils::retry_backoff_sleep(attempt);
1053                                continue;
1054                            } else {
1055                                return Err(anyhow!(
1056                                    "Failed to download file after {} attempts: {}",
1057                                    attempt,
1058                                    e
1059                                ));
1060                            }
1061                        }
1062                    }
1063                };
1064                if !response.status().is_success() {
1065                    return Err(anyhow!(
1066                        "Failed to download alt source (HTTP {}): {}",
1067                        response.status(),
1068                        alt_source
1069                    ));
1070                }
1071
1072                let content = response.text()?;
1073                let temp_path = env::temp_dir().join(format!(
1074                    "zoi-alt-{}.pkg.lua",
1075                    Utc::now().timestamp_nanos_opt().unwrap_or(0)
1076                ));
1077                fs::write(&temp_path, &content)?;
1078                resolve_source_recursive(
1079                    temp_path.to_str().ok_or_else(|| {
1080                        anyhow!(
1081                            "Temporary path contains invalid UTF-8 characters: {:?}",
1082                            temp_path
1083                        )
1084                    })?,
1085                    depth + 1,
1086                    quiet,
1087                )?
1088            } else {
1089                resolve_source_recursive(&alt_source, depth + 1, quiet)?
1090            };
1091
1092        if resolved_source.source_type == SourceType::OfficialRepo {
1093            alt_resolved_source.source_type = SourceType::OfficialRepo;
1094        }
1095
1096        return Ok(alt_resolved_source);
1097    }
1098
1099    Ok(resolved_source)
1100}