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