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