1use 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
16pub fn get_store_base_dir(scope: Scope) -> Result<PathBuf> {
22 utils::get_store_base_dir(scope)
23}
24
25pub 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
44pub 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#[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
77fn 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
87pub 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#[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
118fn 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
159pub 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#[derive(Debug)]
207pub struct InstalledPackage {
208 pub name: String,
210 pub sub_package: Option<String>,
212 pub version: String,
214 pub repo: String,
216 pub package_type: zoi_core::types::PackageType
218}
219
220pub 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
239pub 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
292pub 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
328pub 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
362pub 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
389pub 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
400pub 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
474pub 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
499pub 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
512pub 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
534pub 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
559pub 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 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
600pub 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
616pub 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
633pub 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}