Skip to main content

zoi_resolver/
local.rs

1//! Local package management and store interaction.
2//!
3//! This module provides functions for interacting with the local package store,
4//! listing installed packages, and managing package manifests and dependencies.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use anyhow::{Result, anyhow};
10use walkdir::WalkDir;
11use zoi_core::types::{self, InstallManifest, Scope};
12use zoi_core::{config, utils};
13
14use crate::resolve::{PackageRequest, get_db_root};
15
16/// Returns the base directory of the package store for a given scope.
17///
18/// # Errors
19///
20/// Returns an error if the store base directory cannot be determined.
21pub fn get_store_base_dir(scope: Scope) -> Result<PathBuf> {
22    utils::get_store_base_dir(scope)
23}
24
25/// Returns the directory for a specific package in the store.
26///
27/// # Errors
28///
29/// Returns an error if the package directory path cannot be constructed.
30pub fn get_package_dir(
31    scope: Scope,
32    registry_handle: &str,
33    repo_path: &str,
34    package_name: &str
35) -> Result<PathBuf> {
36    let base_dir = get_store_base_dir(scope)?;
37    let package_id =
38        utils::generate_package_id(registry_handle, repo_path, package_name);
39    let package_dir_name =
40        utils::get_package_dir_name(&package_id, package_name);
41    Ok(base_dir.join(package_dir_name))
42}
43
44/// Returns the directory for a specific version of a package in the store.
45///
46/// # Errors
47///
48/// Returns an error if the package version directory path cannot be
49/// constructed.
50pub fn get_package_version_dir(
51    scope: Scope,
52    registry_handle: &str,
53    repo_path: &str,
54    package_name: &str,
55    version: &str
56) -> Result<PathBuf> {
57    let package_dir =
58        get_package_dir(scope, registry_handle, repo_path, package_name)?;
59    Ok(package_dir.join(version))
60}
61
62use rayon::prelude::*;
63
64/// Returns the store manifest filename for a (sub-)package.
65///
66/// Store manifests are machine-generated install records, so they use JSON:
67/// `manifest.json` (or `manifest-{sub}.json` for split packages).
68#[must_use]
69pub fn manifest_filename(sub_package: Option<&str>) -> String {
70    if let Some(sub) = sub_package {
71        format!("manifest-{sub}.json")
72    } else {
73        "manifest.json".to_string()
74    }
75}
76
77/// Returns the legacy YAML manifest filename, kept for reading stores
78/// written before the JSON migration.
79fn legacy_manifest_filename(sub_package: Option<&str>) -> String {
80    if let Some(sub) = sub_package {
81        format!("manifest-{sub}.yaml")
82    } else {
83        "manifest.yaml".to_string()
84    }
85}
86
87/// Reads a store manifest, accepting the current JSON format as well as
88/// legacy YAML files so existing stores keep working.
89///
90/// # Errors
91///
92/// Returns an error if the file cannot be read or is neither valid JSON
93/// nor valid YAML.
94pub fn read_store_manifest(path: &Path) -> Result<InstallManifest> {
95    let content = fs::read_to_string(path)?;
96    serde_json::from_str(&content)
97        .or_else(|_| serde_yaml::from_str(&content))
98        .map_err(|e| {
99            anyhow!("Failed to parse store manifest '{}': {e}", path.display())
100        })
101}
102
103/// Locates a store manifest inside a version directory, preferring JSON
104/// over legacy YAML.
105#[must_use]
106pub fn find_store_manifest(
107    version_dir: &Path,
108    sub_package: Option<&str>
109) -> Option<PathBuf> {
110    let json = version_dir.join(manifest_filename(sub_package));
111    if json.exists() {
112        return Some(json);
113    }
114    let legacy = version_dir.join(legacy_manifest_filename(sub_package));
115    legacy.exists().then_some(legacy)
116}
117
118/// Lists store manifest paths inside a `latest/` directory, preferring JSON
119/// when both formats exist for the same manifest.
120fn manifest_paths_in_latest(latest: &Path) -> Vec<PathBuf> {
121    let Ok(entries) = fs::read_dir(latest) else {
122        return Vec::new();
123    };
124    let mut json_paths = Vec::new();
125    let mut yaml_paths = Vec::new();
126    for entry in entries.flatten() {
127        let path = entry.path();
128        let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
129            continue;
130        };
131        if !name.starts_with("manifest") {
132            continue;
133        }
134        let ext = path
135            .extension()
136            .and_then(|s| s.to_str())
137            .unwrap_or_default()
138            .to_lowercase();
139        if ext == "json" {
140            json_paths.push(path);
141        } else if ext == "yaml" || ext == "yml" {
142            yaml_paths.push(path);
143        }
144    }
145    for yaml_path in yaml_paths {
146        let dominated = yaml_path.file_stem().is_some_and(|stem| {
147            json_paths.iter().any(|json_path| {
148                json_path.file_stem().is_some_and(|js| js == stem)
149            })
150        });
151        if !dominated {
152            json_paths.push(yaml_path);
153        }
154    }
155    json_paths.sort();
156    json_paths
157}
158
159/// Returns a list of all installed packages across all scopes.
160///
161/// # Errors
162///
163/// Returns an error if the store cannot be accessed or manifests cannot be
164/// read.
165pub fn get_installed_packages() -> Result<Vec<InstallManifest>> {
166    let scopes = [Scope::User, Scope::System, Scope::Project];
167
168    let installed: Vec<InstallManifest> = scopes
169        .into_par_iter()
170        .map(|scope| {
171            let mut manifests = Vec::new();
172            if let Ok(store_root) = get_store_base_dir(scope)
173                && store_root.exists()
174                && let Ok(entries) = fs::read_dir(store_root)
175            {
176                for entry in entries.flatten() {
177                    let path = entry.path();
178                    if !path.is_dir() {
179                        continue;
180                    }
181                    let latest_path = path.join("latest");
182                    if latest_path.is_symlink() || latest_path.is_dir() {
183                        for manifest_path in
184                            manifest_paths_in_latest(&latest_path)
185                        {
186                            if let Ok(manifest) =
187                                read_store_manifest(&manifest_path)
188                            {
189                                manifests.push(manifest);
190                            }
191                        }
192                    }
193                }
194            }
195            manifests
196        })
197        .flatten()
198        .collect();
199
200    let mut sorted_installed = installed;
201    sorted_installed.sort_by(|a, b| a.name.cmp(&b.name));
202    Ok(sorted_installed)
203}
204
205/// Represents an installed package with basic metadata.
206#[derive(Debug)]
207pub struct InstalledPackage {
208    /// The name of the package.
209    pub name: String,
210    /// The sub-package name, if any.
211    pub sub_package: Option<String>,
212    /// The installed version.
213    pub version: String,
214    /// The repository the package was installed from.
215    pub repo: String,
216    /// The type of package.
217    pub package_type: zoi_core::types::PackageType
218}
219
220/// Returns a list of all installed packages with their basic metadata.
221///
222/// # Errors
223///
224/// Returns an error if installed packages cannot be retrieved.
225pub fn get_installed_packages_with_type() -> Result<Vec<InstalledPackage>> {
226    let manifests = get_installed_packages()?;
227    Ok(manifests
228        .into_iter()
229        .map(|m| InstalledPackage {
230            name: m.name,
231            sub_package: m.sub_package,
232            version: m.version,
233            repo: m.repo,
234            package_type: m.package_type
235        })
236        .collect())
237}
238
239/// Checks if a package is installed in a given scope and returns its manifest
240/// if found.
241///
242/// # Errors
243///
244/// Returns an error if the store cannot be accessed or manifests cannot be
245/// read.
246pub fn is_package_installed(
247    package_name: &str,
248    sub_package_name: Option<&str>,
249    scope: Scope
250) -> Result<Option<InstallManifest>> {
251    let store_root = get_store_base_dir(scope)?;
252    if !store_root.exists() {
253        return Ok(None);
254    }
255
256    for entry in fs::read_dir(store_root)? {
257        let entry = entry?;
258        let path = entry.path();
259        if !path.is_dir() {
260            continue;
261        }
262
263        if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
264            let parts: Vec<&str> = file_name.splitn(2, '-').collect();
265            if parts.len() == 2
266                && parts.get(1) == Some(&package_name)
267                && parts.first().is_some_and(|p| p.len() == 32)
268            {
269                let latest_path = path.join("latest");
270                if latest_path.is_symlink() || latest_path.is_dir() {
271                    for manifest_path in manifest_paths_in_latest(&latest_path)
272                    {
273                        let Ok(manifest) = read_store_manifest(&manifest_path)
274                        else {
275                            continue;
276                        };
277                        if manifest.name == package_name
278                            && manifest.sub_package.as_deref()
279                                == sub_package_name
280                        {
281                            return Ok(Some(manifest));
282                        }
283                    }
284                }
285            }
286        }
287    }
288
289    Ok(None)
290}
291
292/// Returns all installed manifests in a specific scope.
293///
294/// # Errors
295///
296/// Returns an error if the store cannot be accessed or manifests cannot be
297/// read.
298pub fn get_installed_manifests_in_scope(
299    scope: Scope
300) -> Result<Vec<InstallManifest>> {
301    let store_root = get_store_base_dir(scope)?;
302    if !store_root.exists() {
303        return Ok(Vec::new());
304    }
305
306    let mut manifests = Vec::new();
307    for entry in fs::read_dir(store_root)? {
308        let entry = entry?;
309        let path = entry.path();
310        if !path.is_dir() {
311            continue;
312        }
313
314        let latest_path = path.join("latest");
315        if !(latest_path.is_symlink() || latest_path.is_dir()) {
316            continue;
317        }
318
319        for manifest_path in manifest_paths_in_latest(&latest_path) {
320            let manifest = read_store_manifest(&manifest_path)?;
321            manifests.push(manifest);
322        }
323    }
324
325    Ok(manifests)
326}
327
328/// Finds installed manifests matching a package request in a specific scope.
329///
330/// Name, repo, and registry handle comparisons are case-insensitive because
331/// source strings are normalized to lowercase during parsing while manifests
332/// store the original casing from package metadata.
333///
334/// # Errors
335///
336/// Returns an error if installed manifests cannot be retrieved.
337pub fn find_installed_manifests_matching(
338    request: &PackageRequest,
339    scope: Scope
340) -> Result<Vec<InstallManifest>> {
341    let manifests = get_installed_manifests_in_scope(scope)?;
342    Ok(manifests
343        .into_iter()
344        .filter(|manifest| {
345            manifest.name.eq_ignore_ascii_case(&request.name)
346                && manifest.sub_package == request.sub_package
347                && request.handle.as_ref().is_none_or(|handle| {
348                    manifest.registry_handle.eq_ignore_ascii_case(handle)
349                })
350                && request
351                    .repo
352                    .as_ref()
353                    .is_none_or(|repo| manifest.repo.eq_ignore_ascii_case(repo))
354                && request
355                    .version_spec
356                    .as_ref()
357                    .is_none_or(|version| manifest.version == *version)
358        })
359        .collect())
360}
361
362/// Formats a package source string from its components.
363pub fn package_source_string(
364    registry_handle: &str,
365    repo: &str,
366    name: &str,
367    sub_package: Option<&str>,
368    version: &str
369) -> String {
370    let registry_handle = registry_handle.trim();
371    let repo = repo.trim();
372    let name = name.trim();
373    let version = version.trim();
374
375    if let Some(sub_package) = sub_package {
376        format!(
377            "#{}@{}/{}:{}@{}",
378            registry_handle,
379            repo,
380            name,
381            sub_package.trim(),
382            version
383        )
384    } else {
385        format!("#{registry_handle}@{repo}/{name}@{version}")
386    }
387}
388
389/// Returns the source string for an installed manifest.
390pub fn installed_manifest_source(manifest: &InstallManifest) -> String {
391    package_source_string(
392        &manifest.registry_handle,
393        &manifest.repo,
394        &manifest.name,
395        manifest.sub_package.as_deref(),
396        &manifest.version
397    )
398}
399
400/// Returns all available packages from a list of repositories.
401///
402/// # Errors
403///
404/// Returns an error if the database root cannot be determined or if package
405/// files cannot be parsed.
406pub fn get_packages_from_repos(
407    repos: &[String]
408) -> Result<Vec<zoi_core::types::Package>> {
409    let db_root = get_db_root()?;
410    if !db_root.exists() {
411        return Err(anyhow::anyhow!(
412            "Package database not found. Please run 'zoi sync' first."
413        ));
414    }
415
416    let mut available = Vec::new();
417
418    for repo_name in repos {
419        let repo_path = db_root.join(repo_name);
420        if !repo_path.exists() {
421            continue;
422        }
423        for entry in WalkDir::new(repo_path).into_iter().filter_map(Result::ok)
424        {
425            if !entry.file_type().is_dir() {
426                continue;
427            }
428
429            let pkg_name = entry.file_name().to_string_lossy();
430            let pkg_file_path =
431                entry.path().join(format!("{pkg_name}.pkg.lua"));
432
433            if pkg_file_path.is_file() {
434                let pkg_file_path_str =
435                    pkg_file_path.to_str().ok_or_else(|| {
436                        anyhow::anyhow!(
437                            "Package path contains invalid UTF-8: {}",
438                            pkg_file_path.display()
439                        )
440                    })?;
441                let mut pkg: zoi_core::types::Package =
442                    zoi_lua::parser::parse_lua_package(
443                        pkg_file_path_str,
444                        None,
445                        None,
446                        true
447                    )?;
448
449                if let Ok(repo_subpath) = entry.path().strip_prefix(&db_root) {
450                    let mut repo_path = repo_subpath
451                        .to_string_lossy()
452                        .to_string()
453                        .replace('\\', "/");
454                    let pkg_name_suffix = format!("/{}", pkg.name);
455                    if repo_path.ends_with(&pkg_name_suffix) {
456                        repo_path = repo_path
457                            [..repo_path.len() - pkg_name_suffix.len()]
458                            .to_string();
459                    } else if repo_path == pkg.name {
460                        repo_path = String::new();
461                    }
462                    pkg.repo = repo_path;
463                }
464
465                available.push(pkg);
466            }
467        }
468    }
469
470    available.sort_by(|a, b| a.name.cmp(&b.name));
471    Ok(available)
472}
473
474/// Returns all available packages in the default registry.
475///
476/// # Errors
477///
478/// Returns an error if the configuration cannot be read or available packages
479/// cannot be retrieved.
480pub fn get_all_available_packages() -> Result<Vec<zoi_core::types::Package>> {
481    let config = config::read_config()?;
482    if let Some(handle) = config
483        .default_registry
484        .as_ref()
485        .map(|r| &r.handle)
486        .filter(|h| !h.is_empty())
487    {
488        let repos_with_handle: Vec<String> = config
489            .repos
490            .iter()
491            .map(|repo| format!("{handle}/{repo}"))
492            .collect();
493        get_packages_from_repos(&repos_with_handle)
494    } else {
495        Ok(Vec::new())
496    }
497}
498
499/// Adds a dependent ID to a package's dependents list.
500///
501/// # Errors
502///
503/// Returns an error if the dependent file cannot be written.
504pub fn add_dependent(package_dir: &Path, dependent_id: &str) -> Result<()> {
505    let dependents_dir = package_dir.join("dependents");
506    fs::create_dir_all(&dependents_dir)?;
507    let dependent_file = dependents_dir.join(hex::encode(dependent_id));
508    fs::write(dependent_file, "")?;
509    Ok(())
510}
511
512/// Removes a dependent ID from a package's dependents list.
513///
514/// # Errors
515///
516/// Returns an error if the dependent file cannot be removed.
517pub fn remove_dependent(package_dir: &Path, dependent_id: &str) -> Result<()> {
518    let dependents_dir = package_dir.join("dependents");
519    if dependents_dir.exists() {
520        let dependent_file = dependents_dir.join(hex::encode(dependent_id));
521        if dependent_file.exists() {
522            fs::remove_file(dependent_file)?;
523        } else if let Some(pos) = dependent_id.rfind('@') {
524            let legacy_id = &dependent_id[..pos];
525            let legacy_file = dependents_dir.join(hex::encode(legacy_id));
526            if legacy_file.exists() {
527                fs::remove_file(legacy_file)?;
528            }
529        }
530    }
531    Ok(())
532}
533
534/// Returns a list of all dependent IDs for a package.
535///
536/// # Errors
537///
538/// Returns an error if the dependents directory cannot be read.
539pub fn get_dependents(package_dir: &Path) -> Result<Vec<String>> {
540    let dependents_dir = package_dir.join("dependents");
541    let mut dependents = Vec::new();
542    if dependents_dir.exists() {
543        for entry in fs::read_dir(dependents_dir)? {
544            let entry = entry?;
545            let path = entry.path();
546            if path.is_file()
547                && let Some(file_name) =
548                    path.file_name().and_then(|s| s.to_str())
549                && let Ok(decoded) = hex::decode(file_name)
550                && let Ok(dependent_id) = String::from_utf8(decoded)
551            {
552                dependents.push(dependent_id);
553            }
554        }
555    }
556    Ok(dependents)
557}
558
559/// Writes an installation manifest to the store and updates the 'latest'
560/// symlink.
561///
562/// # Errors
563///
564/// Returns an error if the manifest cannot be serialized or written.
565pub fn write_manifest(manifest: &InstallManifest) -> Result<()> {
566    let version_dir = get_package_version_dir(
567        manifest.scope,
568        &manifest.registry_handle,
569        &manifest.repo,
570        &manifest.name,
571        &manifest.version
572    )?;
573    fs::create_dir_all(&version_dir)?;
574
575    let manifest_path =
576        version_dir.join(manifest_filename(manifest.sub_package.as_deref()));
577
578    let content = serde_json::to_string_pretty(&manifest)?;
579    fs::write(&manifest_path, content)?;
580
581    // Clean up the legacy YAML twin so readers never see both formats.
582    let legacy_path = version_dir
583        .join(legacy_manifest_filename(manifest.sub_package.as_deref()));
584    if legacy_path != manifest_path {
585        let _ = fs::remove_file(legacy_path);
586    }
587
588    let package_dir = get_package_dir(
589        manifest.scope,
590        &manifest.registry_handle,
591        &manifest.repo,
592        &manifest.name
593    )?;
594    let latest_symlink_path = package_dir.join("latest");
595    zoi_core::utils::symlink_dir(&version_dir, &latest_symlink_path)?;
596
597    Ok(())
598}
599
600/// Returns the path where the package source file should be stored.
601///
602/// # Errors
603///
604/// Returns an error if the source path cannot be constructed.
605pub fn get_package_source_path(manifest: &InstallManifest) -> Result<PathBuf> {
606    let version_dir = get_package_version_dir(
607        manifest.scope,
608        &manifest.registry_handle,
609        &manifest.repo,
610        &manifest.name,
611        &manifest.version
612    )?;
613    Ok(version_dir.join("package.pkg.lua"))
614}
615
616/// Persists the package source file to the store.
617///
618/// # Errors
619///
620/// Returns an error if the source file cannot be copied.
621pub fn persist_package_source(
622    manifest: &InstallManifest,
623    source_path: &Path
624) -> Result<()> {
625    let stored_source_path = get_package_source_path(manifest)?;
626    if let Some(parent) = stored_source_path.parent() {
627        fs::create_dir_all(parent)?;
628    }
629    fs::copy(source_path, stored_source_path)?;
630    Ok(())
631}
632
633/// Updates the installation reason in a package's manifest.
634///
635/// # Errors
636///
637/// Returns an error if the manifest cannot be updated.
638pub fn update_manifest_reason(
639    manifest: &InstallManifest,
640    new_reason: types::InstallReason
641) -> Result<()> {
642    let mut updated_manifest = manifest.clone();
643    updated_manifest.reason = new_reason;
644    write_manifest(&updated_manifest)?;
645    Ok(())
646}