use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use walkdir::WalkDir;
use zoi_core::types::{self, InstallManifest, Scope};
use zoi_core::{config, utils};
use crate::resolve::{PackageRequest, get_db_root};
pub fn get_store_base_dir(scope: Scope) -> Result<PathBuf> {
utils::get_store_base_dir(scope)
}
pub fn get_package_dir(
scope: Scope,
registry_handle: &str,
repo_path: &str,
package_name: &str
) -> Result<PathBuf> {
let base_dir = get_store_base_dir(scope)?;
utils::validate_path_component(package_name)?;
let package_id =
utils::generate_package_id(registry_handle, repo_path, package_name);
let package_dir_name =
utils::get_package_dir_name(&package_id, package_name);
utils::safe_join(&base_dir, Path::new(&package_dir_name))
}
pub fn get_package_version_dir(
scope: Scope,
registry_handle: &str,
repo_path: &str,
package_name: &str,
version: &str
) -> Result<PathBuf> {
let package_dir =
get_package_dir(scope, registry_handle, repo_path, package_name)?;
utils::safe_join(&package_dir, Path::new(version))
}
use rayon::prelude::*;
#[must_use]
pub fn manifest_filename(sub_package: Option<&str>) -> String {
if let Some(sub) = sub_package {
format!("manifest-{sub}.json")
} else {
"manifest.json".to_string()
}
}
fn legacy_manifest_filename(sub_package: Option<&str>) -> String {
if let Some(sub) = sub_package {
format!("manifest-{sub}.yaml")
} else {
"manifest.yaml".to_string()
}
}
pub fn read_store_manifest(path: &Path) -> Result<InstallManifest> {
let content = fs::read_to_string(path)?;
serde_json::from_str(&content)
.or_else(|_| serde_yaml::from_str(&content))
.map_err(|e| {
anyhow!("Failed to parse store manifest '{}': {e}", path.display())
})
}
#[must_use]
pub fn find_store_manifest(
version_dir: &Path,
sub_package: Option<&str>
) -> Option<PathBuf> {
let json = version_dir.join(manifest_filename(sub_package));
if json.exists() {
return Some(json);
}
let legacy = version_dir.join(legacy_manifest_filename(sub_package));
legacy.exists().then_some(legacy)
}
fn manifest_paths_in_latest(latest: &Path) -> Vec<PathBuf> {
let Ok(entries) = fs::read_dir(latest) else {
return Vec::new();
};
let mut json_paths = Vec::new();
let mut yaml_paths = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if !name.starts_with("manifest") {
continue;
}
let ext = path
.extension()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_lowercase();
if ext == "json" {
json_paths.push(path);
} else if ext == "yaml" || ext == "yml" {
yaml_paths.push(path);
}
}
for yaml_path in yaml_paths {
let dominated = yaml_path.file_stem().is_some_and(|stem| {
json_paths.iter().any(|json_path| {
json_path.file_stem().is_some_and(|js| js == stem)
})
});
if !dominated {
json_paths.push(yaml_path);
}
}
json_paths.sort();
json_paths
}
pub fn get_installed_packages() -> Result<Vec<InstallManifest>> {
let scopes = [Scope::User, Scope::System, Scope::Project];
let installed: Vec<InstallManifest> = scopes
.into_par_iter()
.map(|scope| {
let mut manifests = Vec::new();
if let Ok(store_root) = get_store_base_dir(scope)
&& store_root.exists()
&& let Ok(entries) = fs::read_dir(store_root)
{
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let latest_path = path.join("latest");
if latest_path.is_symlink() || latest_path.is_dir() {
for manifest_path in
manifest_paths_in_latest(&latest_path)
{
if let Ok(manifest) =
read_store_manifest(&manifest_path)
{
manifests.push(manifest);
}
}
}
}
}
manifests
})
.flatten()
.collect();
let mut sorted_installed = installed;
sorted_installed.sort_by(|a, b| a.name.cmp(&b.name));
Ok(sorted_installed)
}
#[derive(Debug)]
pub struct InstalledPackage {
pub name: String,
pub sub_package: Option<String>,
pub version: String,
pub repo: String,
pub package_type: zoi_core::types::PackageType
}
pub fn get_installed_packages_with_type() -> Result<Vec<InstalledPackage>> {
let manifests = get_installed_packages()?;
Ok(manifests
.into_iter()
.map(|m| InstalledPackage {
name: m.name,
sub_package: m.sub_package,
version: m.version,
repo: m.repo,
package_type: m.package_type
})
.collect())
}
pub fn is_package_installed(
package_name: &str,
sub_package_name: Option<&str>,
scope: Scope
) -> Result<Option<InstallManifest>> {
let store_root = get_store_base_dir(scope)?;
if !store_root.exists() {
return Ok(None);
}
for entry in fs::read_dir(store_root)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
let parts: Vec<&str> = file_name.splitn(2, '-').collect();
if parts.len() == 2
&& parts.get(1) == Some(&package_name)
&& parts.first().is_some_and(|p| p.len() == 32)
{
let latest_path = path.join("latest");
if latest_path.is_symlink() || latest_path.is_dir() {
for manifest_path in manifest_paths_in_latest(&latest_path)
{
let Ok(manifest) = read_store_manifest(&manifest_path)
else {
continue;
};
if manifest.name == package_name
&& manifest.sub_package.as_deref()
== sub_package_name
{
return Ok(Some(manifest));
}
}
}
}
}
}
Ok(None)
}
pub fn get_installed_manifests_in_scope(
scope: Scope
) -> Result<Vec<InstallManifest>> {
let store_root = get_store_base_dir(scope)?;
if !store_root.exists() {
return Ok(Vec::new());
}
let mut manifests = Vec::new();
for entry in fs::read_dir(store_root)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let latest_path = path.join("latest");
if !(latest_path.is_symlink() || latest_path.is_dir()) {
continue;
}
for manifest_path in manifest_paths_in_latest(&latest_path) {
let manifest = read_store_manifest(&manifest_path)?;
manifests.push(manifest);
}
}
Ok(manifests)
}
pub fn find_installed_manifests_matching(
request: &PackageRequest,
scope: Scope
) -> Result<Vec<InstallManifest>> {
let manifests = get_installed_manifests_in_scope(scope)?;
Ok(manifests
.into_iter()
.filter(|manifest| {
manifest.name.eq_ignore_ascii_case(&request.name)
&& manifest.sub_package == request.sub_package
&& request.handle.as_ref().is_none_or(|handle| {
manifest.registry_handle.eq_ignore_ascii_case(handle)
})
&& request
.repo
.as_ref()
.is_none_or(|repo| manifest.repo.eq_ignore_ascii_case(repo))
&& request
.version_spec
.as_ref()
.is_none_or(|version| manifest.version == *version)
})
.collect())
}
pub fn package_source_string(
registry_handle: &str,
repo: &str,
name: &str,
sub_package: Option<&str>,
version: &str
) -> String {
let registry_handle = registry_handle.trim();
let repo = repo.trim();
let name = name.trim();
let version = version.trim();
if let Some(sub_package) = sub_package {
format!(
"#{}@{}/{}:{}@{}",
registry_handle,
repo,
name,
sub_package.trim(),
version
)
} else {
format!("#{registry_handle}@{repo}/{name}@{version}")
}
}
pub fn installed_manifest_source(manifest: &InstallManifest) -> String {
package_source_string(
&manifest.registry_handle,
&manifest.repo,
&manifest.name,
manifest.sub_package.as_deref(),
&manifest.version
)
}
pub fn get_packages_from_repos(
repos: &[String]
) -> Result<Vec<zoi_core::types::Package>> {
let db_root = get_db_root()?;
if !db_root.exists() {
return Err(anyhow::anyhow!(
"Package database not found. Please run 'zoi sync' first."
));
}
let mut available = Vec::new();
for repo_name in repos {
let repo_path = db_root.join(repo_name);
if !repo_path.exists() {
continue;
}
for entry in WalkDir::new(repo_path).into_iter().filter_map(Result::ok)
{
if !entry.file_type().is_dir() {
continue;
}
let pkg_name = entry.file_name().to_string_lossy();
let pkg_file_path =
entry.path().join(format!("{pkg_name}.pkg.lua"));
if pkg_file_path.is_file() {
let pkg_file_path_str =
pkg_file_path.to_str().ok_or_else(|| {
anyhow::anyhow!(
"Package path contains invalid UTF-8: {}",
pkg_file_path.display()
)
})?;
let mut pkg: zoi_core::types::Package =
zoi_lua::parser::parse_lua_package(
pkg_file_path_str,
None,
None,
true
)?;
if let Ok(repo_subpath) = entry.path().strip_prefix(&db_root) {
let mut repo_path = repo_subpath
.to_string_lossy()
.to_string()
.replace('\\', "/");
let pkg_name_suffix = format!("/{}", pkg.name);
if repo_path.ends_with(&pkg_name_suffix) {
repo_path = repo_path
[..repo_path.len() - pkg_name_suffix.len()]
.to_string();
} else if repo_path == pkg.name {
repo_path = String::new();
}
pkg.repo = repo_path;
}
available.push(pkg);
}
}
}
available.sort_by(|a, b| a.name.cmp(&b.name));
Ok(available)
}
pub fn get_all_available_packages() -> Result<Vec<zoi_core::types::Package>> {
let config = config::read_config()?;
if let Some(handle) = config
.default_registry
.as_ref()
.map(|r| &r.handle)
.filter(|h| !h.is_empty())
{
let repos_with_handle: Vec<String> = config
.repos
.iter()
.map(|repo| format!("{handle}/{repo}"))
.collect();
get_packages_from_repos(&repos_with_handle)
} else {
Ok(Vec::new())
}
}
pub fn add_dependent(package_dir: &Path, dependent_id: &str) -> Result<()> {
let dependents_dir = package_dir.join("dependents");
fs::create_dir_all(&dependents_dir)?;
let dependent_file = dependents_dir.join(hex::encode(dependent_id));
fs::write(dependent_file, "")?;
Ok(())
}
pub fn remove_dependent(package_dir: &Path, dependent_id: &str) -> Result<()> {
let dependents_dir = package_dir.join("dependents");
if dependents_dir.exists() {
let dependent_file = dependents_dir.join(hex::encode(dependent_id));
if dependent_file.exists() {
fs::remove_file(dependent_file)?;
} else if let Some(pos) = dependent_id.rfind('@') {
let legacy_id = &dependent_id[..pos];
let legacy_file = dependents_dir.join(hex::encode(legacy_id));
if legacy_file.exists() {
fs::remove_file(legacy_file)?;
}
}
}
Ok(())
}
pub fn get_dependents(package_dir: &Path) -> Result<Vec<String>> {
let dependents_dir = package_dir.join("dependents");
let mut dependents = Vec::new();
if dependents_dir.exists() {
for entry in fs::read_dir(dependents_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file()
&& let Some(file_name) =
path.file_name().and_then(|s| s.to_str())
&& let Ok(decoded) = hex::decode(file_name)
&& let Ok(dependent_id) = String::from_utf8(decoded)
{
dependents.push(dependent_id);
}
}
}
Ok(dependents)
}
pub fn write_manifest(manifest: &InstallManifest) -> Result<()> {
let version_dir = get_package_version_dir(
manifest.scope,
&manifest.registry_handle,
&manifest.repo,
&manifest.name,
&manifest.version
)?;
fs::create_dir_all(&version_dir)?;
let manifest_path =
version_dir.join(manifest_filename(manifest.sub_package.as_deref()));
let content = serde_json::to_string_pretty(&manifest)?;
fs::write(&manifest_path, content)?;
let legacy_path = version_dir
.join(legacy_manifest_filename(manifest.sub_package.as_deref()));
if legacy_path != manifest_path {
let _ = fs::remove_file(legacy_path);
}
let package_dir = get_package_dir(
manifest.scope,
&manifest.registry_handle,
&manifest.repo,
&manifest.name
)?;
let latest_symlink_path = package_dir.join("latest");
zoi_core::utils::symlink_dir(&version_dir, &latest_symlink_path)?;
Ok(())
}
pub fn get_package_source_path(manifest: &InstallManifest) -> Result<PathBuf> {
let version_dir = get_package_version_dir(
manifest.scope,
&manifest.registry_handle,
&manifest.repo,
&manifest.name,
&manifest.version
)?;
Ok(version_dir.join("package.pkg.lua"))
}
pub fn persist_package_source(
manifest: &InstallManifest,
source_path: &Path
) -> Result<()> {
let stored_source_path = get_package_source_path(manifest)?;
if let Some(parent) = stored_source_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(source_path, stored_source_path)?;
Ok(())
}
pub fn update_manifest_reason(
manifest: &InstallManifest,
new_reason: types::InstallReason
) -> Result<()> {
let mut updated_manifest = manifest.clone();
updated_manifest.reason = new_reason;
write_manifest(&updated_manifest)?;
Ok(())
}