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 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
45pub 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#[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
78fn 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
88pub 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#[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
119fn 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
160pub 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#[derive(Debug)]
208pub struct InstalledPackage {
209 pub name: String,
211 pub sub_package: Option<String>,
213 pub version: String,
215 pub repo: String,
217 pub package_type: zoi_core::types::PackageType
219}
220
221pub 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
240pub 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
293pub 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
329pub 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
363pub 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
390pub 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
401pub 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
475pub 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
500pub 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
513pub 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
535pub 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
560pub 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 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
601pub 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
617pub 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
634pub 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}