1use super::discovery::{discover_extensions, discover_prompts, discover_skills, discover_themes};
10use super::fs::{copy_dir_recursive, find_single_subdir, prune_empty_parents};
11use super::git_ops::{git_clone, git_has_update, git_update};
12use super::lockfile::{
13 LockEntry, Lockfile, ResourceCounts, compute_dir_hash, verify_lockfile_integrity,
14};
15use super::npm::{NpmPackageInfo, get_latest_npm_version};
16use super::source::{ParsedSource, parse_npm_spec};
17use super::types::{
18 ConfiguredPackage, DiscoveredResource, PackageManifest, PackageUpdateInfo, PathMetadata,
19 ProgressAction, ProgressEvent, ProgressEventType, ResolvedPaths, ResolvedResource,
20 ResourceKind, ResourceOrigin, SourceScope,
21};
22use super::{LOCKFILE_NAME, MANIFEST_NAME, NPM_MANIFEST_NAME};
23use crate::util::http_client::shared_http_client;
24use anyhow::{Context, Result, bail};
25use std::collections::{BTreeMap, HashMap, HashSet};
26use std::fs;
27use std::path::{Path, PathBuf};
28
29pub struct PackageManager {
31 packages_dir: PathBuf,
32 project_dir: PathBuf,
34 installed: HashMap<String, PackageManifest>,
35 lockfile: Lockfile,
36 progress_callback: Option<Box<dyn Fn(ProgressEvent) + Send + Sync>>,
37}
38
39impl PackageManager {
40 pub fn new() -> Result<Self> {
42 let base = dirs::home_dir().context("Cannot determine home directory")?;
43 let packages_dir = base.join(".oxicode").join("packages");
44 let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
45 let mut mgr = Self {
46 packages_dir,
47 project_dir,
48 installed: HashMap::new(),
49 lockfile: Lockfile::new(),
50 progress_callback: None,
51 };
52 mgr.load_installed()?;
53 mgr.load_lockfile()?;
54 Ok(mgr)
55 }
56
57 pub fn with_dir(packages_dir: PathBuf) -> Result<Self> {
59 let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
60 let mut mgr = Self {
61 packages_dir,
62 project_dir,
63 installed: HashMap::new(),
64 lockfile: Lockfile::new(),
65 progress_callback: None,
66 };
67 mgr.load_installed()?;
68 mgr.load_lockfile()?;
69 Ok(mgr)
70 }
71
72 pub fn set_project_dir(&mut self, dir: PathBuf) {
74 self.project_dir = dir;
75 }
76
77 pub fn set_progress_callback(&mut self, callback: Box<dyn Fn(ProgressEvent) + Send + Sync>) {
79 self.progress_callback = Some(callback);
80 }
81
82 fn emit_progress(&self, event: ProgressEvent) {
83 if let Some(ref cb) = self.progress_callback {
84 cb(event);
85 }
86 }
87
88 fn load_installed(&mut self) -> Result<()> {
102 if !self.packages_dir.exists() {
103 return Ok(());
104 }
105 for entry in fs::read_dir(&self.packages_dir)? {
106 let entry = entry?;
107 let manifest_path = entry.path().join(MANIFEST_NAME);
108 if manifest_path.exists() {
109 match Self::read_manifest(&manifest_path) {
110 Ok(manifest) => {
111 let name = manifest.name.clone();
112 let install_dir = entry.path();
113
114 if let Some(expected) = self
122 .lockfile
123 .packages
124 .get(&name)
125 .and_then(|e| e.integrity.as_ref())
126 {
127 match verify_lockfile_integrity(&install_dir, expected) {
128 Ok(()) => {}
129 Err(reason) => {
130 tracing::warn!(
131 package = %name,
132 expected = %expected,
133 reason = %reason,
134 "package integrity mismatch on load — treating as un-installed; re-install with `oxicode pkg install`"
135 );
136 self.lockfile.packages.remove(&name);
139 continue;
140 }
141 }
142 }
143
144 self.installed.insert(name, manifest);
145 }
146 Err(e) => {
147 tracing::warn!(
148 "Failed to load manifest {}: {}",
149 manifest_path.display(),
150 e
151 );
152 }
153 }
154 }
155 }
156 Ok(())
157 }
158
159 fn load_lockfile(&mut self) -> Result<()> {
161 let lock_path = self.packages_dir.join(LOCKFILE_NAME);
162 if let Some(lock) = Lockfile::read(&lock_path)? {
163 self.lockfile = lock;
164 }
165 Ok(())
166 }
167
168 fn save_lockfile(&self) -> Result<()> {
170 let lock_path = self.packages_dir.join(LOCKFILE_NAME);
171 self.lockfile.write(&lock_path)
172 }
173
174 fn read_manifest(path: &Path) -> Result<PackageManifest> {
178 let content = fs::read_to_string(path)
179 .with_context(|| format!("Failed to read manifest {}", path.display()))?;
180 let manifest: PackageManifest = toml::from_str(&content)
181 .with_context(|| format!("Failed to parse manifest {}", path.display()))?;
182 Ok(manifest)
183 }
184
185 fn read_package_json(dir: &Path) -> Option<serde_json::Value> {
187 let path = dir.join(NPM_MANIFEST_NAME);
188 let content = fs::read_to_string(path).ok()?;
189 serde_json::from_str(&content).ok()
190 }
191
192 fn pkg_install_dir(&self, name: &str) -> PathBuf {
196 let safe_name = name.replace('@', "").replace('/', "-");
197 self.packages_dir.join(safe_name)
198 }
199
200 pub fn packages_dir(&self) -> &Path {
202 &self.packages_dir
203 }
204
205 fn git_install_path(&self, host: &str, path: &str, scope: SourceScope) -> PathBuf {
207 match scope {
208 SourceScope::Project => self
209 .project_dir
210 .join(".oxicode")
211 .join("git")
212 .join(host)
213 .join(path),
214 SourceScope::User => self.packages_dir.join("git").join(host).join(path),
215 }
216 }
217
218 fn npm_install_path(&self, name: &str, scope: SourceScope) -> PathBuf {
220 let safe_name = name.replace('@', "").replace('/', "-");
221 match scope {
222 SourceScope::Project => self
223 .project_dir
224 .join(".oxicode")
225 .join("npm")
226 .join(safe_name),
227 SourceScope::User => self.packages_dir.join("npm").join(safe_name),
228 }
229 }
230
231 fn ensure_packages_dir(&self) -> Result<()> {
235 fs::create_dir_all(&self.packages_dir).with_context(|| {
236 format!(
237 "Failed to create packages directory {}",
238 self.packages_dir.display()
239 )
240 })
241 }
242
243 pub fn install(&mut self, source: &str) -> Result<PackageManifest> {
245 let parsed = ParsedSource::parse(source);
246 match parsed {
247 ParsedSource::Local { path } => self.install_local(&path),
248 _ => bail!("Use install_from_source() for non-local packages"),
249 }
250 }
251
252 fn install_local(&mut self, path: &str) -> Result<PackageManifest> {
254 let source_path = Path::new(path);
255 let manifest_path = source_path.join(MANIFEST_NAME);
256
257 let manifest = if manifest_path.exists() {
258 Self::read_manifest(&manifest_path)
259 .with_context(|| format!("No valid {} found in {}", MANIFEST_NAME, path))?
260 } else {
261 let name = source_path
263 .file_name()
264 .map(|n| n.to_string_lossy().to_string())
265 .unwrap_or_else(|| "unknown".to_string());
266 PackageManifest {
267 name,
268 version: "0.0.0".to_string(),
269 extensions: Vec::new(),
270 skills: Vec::new(),
271 prompts: Vec::new(),
272 themes: Vec::new(),
273 description: None,
274 dependencies: BTreeMap::new(),
275 }
276 };
277
278 let dest = self.pkg_install_dir(&manifest.name);
279 self.ensure_packages_dir()?;
280
281 if dest.exists() {
282 fs::remove_dir_all(&dest).with_context(|| {
283 format!("Failed to remove existing package at {}", dest.display())
284 })?;
285 }
286
287 copy_dir_recursive(source_path, &dest).with_context(|| {
288 format!("Failed to copy package from {} to {}", path, dest.display())
289 })?;
290
291 let integrity = compute_dir_hash(&dest);
292
293 self.lockfile.insert(LockEntry {
294 source: path.to_string(),
295 name: manifest.name.clone(),
296 version: manifest.version.clone(),
297 integrity,
298 scope: SourceScope::User,
299 source_type: "local".to_string(),
300 dependencies: manifest.dependencies.clone(),
301 });
302
303 self.installed
304 .insert(manifest.name.clone(), manifest.clone());
305 self.save_lockfile()
306 .context("failed to persist package lockfile")?;
307 Ok(manifest)
308 }
309
310 pub fn install_from_source(
312 &mut self,
313 source: &str,
314 scope: SourceScope,
315 ) -> Result<PackageManifest> {
316 let parsed = ParsedSource::parse(source);
317 if let Err(reason) = super::source::validate_parsed_source(&parsed) {
318 bail!(
319 "refusing to install '{source}': {reason}; \
320 the install command would forward this string to npm/git, \
321 so metacharacters are rejected at the boundary"
322 );
323 }
324 self.emit_progress(ProgressEvent {
325 event_type: ProgressEventType::Start,
326 action: ProgressAction::Install,
327 source: source.to_string(),
328 message: Some(format!("Installing {}...", source)),
329 });
330 let result = match &parsed {
331 ParsedSource::Npm { .. } => run_on_fresh_runtime(self.install_npm_async(source, scope)),
332 ParsedSource::Git { repo, ref_, .. } => {
333 self.install_git_sync(source, repo, ref_.as_deref(), scope)
334 }
335 ParsedSource::Local { path } => self.install_local(path),
336 ParsedSource::Url { url } => run_on_fresh_runtime(self.install_url(url, scope)),
337 };
338 match &result {
339 Ok(_) => self.emit_progress(ProgressEvent {
340 event_type: ProgressEventType::Complete,
341 action: ProgressAction::Install,
342 source: source.to_string(),
343 message: None,
344 }),
345 Err(e) => self.emit_progress(ProgressEvent {
346 event_type: ProgressEventType::Error,
347 action: ProgressAction::Install,
348 source: source.to_string(),
349 message: Some(e.to_string()),
350 }),
351 }
352 result
353 }
354
355 async fn install_npm_async(
357 &mut self,
358 source: &str,
359 scope: SourceScope,
360 ) -> Result<PackageManifest> {
361 let parsed = ParsedSource::parse(source);
362 let (spec, name, pinned) = match &parsed {
363 ParsedSource::Npm { spec, name, pinned } => (spec.clone(), name.clone(), *pinned),
364 _ => bail!("Expected npm source"),
365 };
366
367 let _version = if pinned {
369 let (_, ver) = parse_npm_spec(&spec);
371 if ver {
372 spec.rsplit('@').next().unwrap_or("latest").to_string()
373 } else {
374 "latest".to_string()
375 }
376 } else {
377 get_latest_npm_version(&name)
378 .await
379 .unwrap_or_else(|_| "latest".to_string())
380 };
381
382 self.install_npm_pack(&spec, scope)
384 }
385
386 fn install_npm_pack(&mut self, spec: &str, scope: SourceScope) -> Result<PackageManifest> {
388 let tmp_dir =
389 tempfile::tempdir().context("Failed to create temp directory for npm install")?;
390
391 let output = std::process::Command::new("npm")
392 .args(["pack", spec, "--pack-destination"])
393 .arg(tmp_dir.path())
394 .current_dir(tmp_dir.path())
395 .output()
396 .context("Failed to run npm pack")?;
397
398 if !output.status.success() {
399 let stderr = String::from_utf8_lossy(&output.stderr);
400 bail!("npm pack failed for '{}': {}", spec, stderr);
401 }
402
403 let tarball = fs::read_dir(tmp_dir.path())?
405 .filter_map(|e| e.ok())
406 .find(|e| {
407 e.path()
408 .extension()
409 .map(|ext| ext == "tgz")
410 .unwrap_or(false)
411 })
412 .map(|e| e.path())
413 .context("No .tgz file found after npm pack")?;
414
415 let extract_dir = tmp_dir.path().join("extracted");
417 fs::create_dir_all(&extract_dir)?;
418
419 let tar_status = std::process::Command::new("tar")
420 .args(["-xzf", &tarball.to_string_lossy(), "-C"])
421 .arg(&extract_dir)
422 .output()
423 .context("Failed to run tar")?;
424
425 if !tar_status.status.success() {
426 let stderr = String::from_utf8_lossy(&tar_status.stderr);
427 bail!("tar extraction failed: {}", stderr);
428 }
429
430 let pkg_source = extract_dir.join("package");
432 let source_for_copy = if pkg_source.exists() {
433 &pkg_source
434 } else {
435 extract_dir.as_path()
437 };
438
439 self.ensure_packages_dir()?;
440
441 let manifest = if source_for_copy.join(MANIFEST_NAME).exists() {
443 Self::read_manifest(&source_for_copy.join(MANIFEST_NAME))?
444 } else if source_for_copy.join(NPM_MANIFEST_NAME).exists() {
445 let pj = Self::read_package_json(source_for_copy);
446 let (pkg_name, pkg_version) = pj
447 .as_ref()
448 .map(|v| {
449 (
450 v.get("name")
451 .and_then(|n| n.as_str())
452 .unwrap_or(spec)
453 .to_string(),
454 v.get("version")
455 .and_then(|v| v.as_str())
456 .unwrap_or("0.0.0")
457 .to_string(),
458 )
459 })
460 .unwrap_or((spec.to_string(), "0.0.0".to_string()));
461
462 PackageManifest {
463 name: pkg_name,
464 version: pkg_version,
465 extensions: Vec::new(),
466 skills: Vec::new(),
467 prompts: Vec::new(),
468 themes: Vec::new(),
469 description: None,
470 dependencies: BTreeMap::new(),
471 }
472 } else {
473 PackageManifest {
474 name: spec.to_string(),
475 version: "0.0.0".to_string(),
476 extensions: Vec::new(),
477 skills: Vec::new(),
478 prompts: Vec::new(),
479 themes: Vec::new(),
480 description: None,
481 dependencies: BTreeMap::new(),
482 }
483 };
484
485 let dest = self.pkg_install_dir(&manifest.name);
486 if dest.exists() {
487 fs::remove_dir_all(&dest).with_context(|| {
488 format!("Failed to remove existing package at {}", dest.display())
489 })?;
490 }
491
492 copy_dir_recursive(source_for_copy, &dest)
493 .with_context(|| format!("Failed to copy npm package for '{}'", spec))?;
494
495 let integrity = compute_dir_hash(&dest);
496
497 self.lockfile.insert(LockEntry {
498 source: format!("npm:{}", spec),
499 name: manifest.name.clone(),
500 version: manifest.version.clone(),
501 integrity,
502 scope,
503 source_type: "npm".to_string(),
504 dependencies: manifest.dependencies.clone(),
505 });
506
507 self.installed
508 .insert(manifest.name.clone(), manifest.clone());
509 self.save_lockfile()
510 .context("failed to persist package lockfile")?;
511 Ok(manifest)
512 }
513
514 fn install_git_sync(
516 &mut self,
517 source: &str,
518 repo: &str,
519 ref_: Option<&str>,
520 scope: SourceScope,
521 ) -> Result<PackageManifest> {
522 let parsed = ParsedSource::parse(source);
523 let (host, path) = match &parsed {
524 ParsedSource::Git { host, path, .. } => (host.clone(), path.clone()),
525 _ => bail!("Expected git source"),
526 };
527
528 let target_dir = self.git_install_path(&host, &path, scope);
529
530 if target_dir.exists() {
531 return self.load_manifest_from_dir(&target_dir, source, scope);
533 }
534
535 let Some(parent) = target_dir.parent() else {
536 bail!(
537 "Invalid install path: no parent directory for {}",
538 target_dir.display()
539 );
540 };
541 fs::create_dir_all(parent)
542 .with_context(|| format!("Failed to create parent dir for {}", target_dir.display()))?;
543
544 git_clone(repo, &target_dir, ref_)?;
545
546 if target_dir.join(NPM_MANIFEST_NAME).exists() {
548 let _ = std::process::Command::new("npm")
549 .args(["install", "--omit=dev"])
550 .current_dir(&target_dir)
551 .output();
552 }
553
554 self.load_manifest_from_dir(&target_dir, source, scope)
555 }
556
557 fn load_manifest_from_dir(
559 &mut self,
560 dir: &Path,
561 source: &str,
562 scope: SourceScope,
563 ) -> Result<PackageManifest> {
564 let manifest = if dir.join(MANIFEST_NAME).exists() {
565 Self::read_manifest(&dir.join(MANIFEST_NAME))?
566 } else {
567 let name = dir
568 .file_name()
569 .map(|n| n.to_string_lossy().to_string())
570 .unwrap_or_else(|| "unknown".to_string());
571 PackageManifest {
572 name,
573 version: "0.0.0".to_string(),
574 extensions: Vec::new(),
575 skills: Vec::new(),
576 prompts: Vec::new(),
577 themes: Vec::new(),
578 description: None,
579 dependencies: BTreeMap::new(),
580 }
581 };
582
583 let integrity = compute_dir_hash(dir);
584
585 self.lockfile.insert(LockEntry {
586 source: source.to_string(),
587 name: manifest.name.clone(),
588 version: manifest.version.clone(),
589 integrity,
590 scope,
591 source_type: "git".to_string(),
592 dependencies: manifest.dependencies.clone(),
593 });
594
595 self.installed
596 .insert(manifest.name.clone(), manifest.clone());
597 self.save_lockfile()
598 .context("failed to persist package lockfile")?;
599 Ok(manifest)
600 }
601
602 async fn install_url(&mut self, url: &str, scope: SourceScope) -> Result<PackageManifest> {
604 let client = shared_http_client();
605
606 let resp = client.get(url).send().await?;
607 if !resp.status().is_success() {
608 bail!("Failed to download {}: {}", url, resp.status());
609 }
610
611 let bytes = resp.bytes().await?;
612
613 let tmp_dir = tempfile::tempdir()?;
614 let archive_name = url.split('/').next_back().unwrap_or("archive");
615 let archive_path = tmp_dir.path().join(archive_name);
616 fs::write(&archive_path, &bytes)?;
617
618 let extract_dir = tmp_dir.path().join("extracted");
619 fs::create_dir_all(&extract_dir)?;
620
621 if archive_name.ends_with(".tar.gz") || archive_name.ends_with(".tgz") {
622 let status = std::process::Command::new("tar")
623 .args(["-xzf", &archive_path.to_string_lossy(), "-C"])
624 .arg(&extract_dir)
625 .output()?;
626 if !status.status.success() {
627 bail!("Failed to extract archive");
628 }
629 } else if archive_name.ends_with(".zip") {
630 let status = std::process::Command::new("unzip")
632 .arg("-o")
633 .arg(&archive_path)
634 .arg("-d")
635 .arg(&extract_dir)
636 .output()?;
637 if !status.status.success() {
638 bail!("Failed to extract zip archive");
639 }
640 } else {
641 bail!("Unsupported archive format: {}", archive_name);
642 }
643
644 let pkg_dir = find_single_subdir(&extract_dir).unwrap_or_else(|| extract_dir.to_path_buf());
646
647 self.ensure_packages_dir()?;
648
649 let manifest = if pkg_dir.join(MANIFEST_NAME).exists() {
650 Self::read_manifest(&pkg_dir.join(MANIFEST_NAME))?
651 } else {
652 let name = url
653 .split('/')
654 .next_back()
655 .unwrap_or("url-package")
656 .trim_end_matches(".tar.gz")
657 .trim_end_matches(".tgz")
658 .trim_end_matches(".zip")
659 .to_string();
660 PackageManifest {
661 name,
662 version: "0.0.0".to_string(),
663 extensions: Vec::new(),
664 skills: Vec::new(),
665 prompts: Vec::new(),
666 themes: Vec::new(),
667 description: None,
668 dependencies: BTreeMap::new(),
669 }
670 };
671
672 let dest = self.pkg_install_dir(&manifest.name);
673 if dest.exists() {
674 fs::remove_dir_all(&dest)?;
675 }
676
677 copy_dir_recursive(&pkg_dir, &dest)?;
678
679 let integrity = compute_dir_hash(&dest);
680
681 self.lockfile.insert(LockEntry {
682 source: url.to_string(),
683 name: manifest.name.clone(),
684 version: manifest.version.clone(),
685 integrity,
686 scope,
687 source_type: "url".to_string(),
688 dependencies: manifest.dependencies.clone(),
689 });
690
691 self.installed
692 .insert(manifest.name.clone(), manifest.clone());
693 self.save_lockfile()
694 .context("failed to persist package lockfile")?;
695 Ok(manifest)
696 }
697
698 pub fn install_npm(&mut self, name: &str) -> Result<PackageManifest> {
700 if let Err(reason) = super::source::validate_npm_spec(name) {
701 bail!("refusing to install npm package '{name}': {reason}");
702 }
703 self.install_npm_pack(name, SourceScope::User)
704 }
705
706 pub fn uninstall(&mut self, name: &str) -> Result<()> {
710 if !self.installed.contains_key(name) {
711 bail!("Package '{}' is not installed", name);
712 }
713
714 let dest = self.pkg_install_dir(name);
715 if dest.exists() {
716 fs::remove_dir_all(&dest).with_context(|| {
717 format!("Failed to remove package directory {}", dest.display())
718 })?;
719 }
720
721 let _ = self.lockfile.remove(name);
724 self.save_lockfile()
725 .context("failed to persist package lockfile")?;
726
727 self.installed.remove(name);
728 Ok(())
729 }
730
731 pub fn uninstall_from_source(&mut self, source: &str, scope: SourceScope) -> Result<()> {
733 let parsed = ParsedSource::parse(source);
734 self.emit_progress(ProgressEvent {
735 event_type: ProgressEventType::Start,
736 action: ProgressAction::Remove,
737 source: source.to_string(),
738 message: Some(format!("Removing {}...", source)),
739 });
740 let result = self.do_uninstall_from_source(&parsed, scope);
741 match &result {
742 Ok(_) => self.emit_progress(ProgressEvent {
743 event_type: ProgressEventType::Complete,
744 action: ProgressAction::Remove,
745 source: source.to_string(),
746 message: None,
747 }),
748 Err(e) => self.emit_progress(ProgressEvent {
749 event_type: ProgressEventType::Error,
750 action: ProgressAction::Remove,
751 source: source.to_string(),
752 message: Some(e.to_string()),
753 }),
754 }
755 result
756 }
757
758 fn do_uninstall_from_source(
759 &mut self,
760 parsed: &ParsedSource,
761 scope: SourceScope,
762 ) -> Result<()> {
763 match parsed {
764 ParsedSource::Npm { name, .. } => {
765 let dest = self.npm_install_path(name, scope);
766 if dest.exists() {
767 fs::remove_dir_all(&dest)?;
768 }
769 self.installed.remove(name);
770 self.lockfile.remove(name);
771 self.save_lockfile()
772 .context("failed to persist package lockfile")?;
773 Ok(())
774 }
775 ParsedSource::Git { host, path, .. } => {
776 let dest = self.git_install_path(host, path, scope);
777 if dest.exists() {
778 fs::remove_dir_all(&dest)?;
779 prune_empty_parents(&dest, &self.packages_dir);
780 }
781 self.installed.retain(|_, m| {
782 let parsed_m = ParsedSource::parse(m.name.as_str());
783 parsed_m.identity() != parsed.identity()
784 });
785 self.lockfile.packages.retain(|_, entry| {
786 let parsed_e = ParsedSource::parse(&entry.source);
787 parsed_e.identity() != parsed.identity()
788 });
789 self.save_lockfile()
790 .context("failed to persist package lockfile")?;
791 Ok(())
792 }
793 ParsedSource::Local { .. } => Ok(()),
794 ParsedSource::Url { .. } => {
795 let identity = parsed.identity();
796 self.lockfile
797 .packages
798 .retain(|_, e| ParsedSource::parse(&e.source).identity() != identity);
799 self.save_lockfile()
800 .context("failed to persist package lockfile")?;
801 Ok(())
802 }
803 }
804 }
805
806 pub fn update(&mut self, name: &str) -> Result<PackageManifest> {
813 let lock_entry = self.lockfile.get(name).cloned();
814
815 if let Some(entry) = lock_entry {
816 let parsed = ParsedSource::parse(&entry.source);
817 if let Err(reason) = super::source::validate_parsed_source(&parsed) {
818 bail!(
819 "refusing to update '{name}': lockfile source '{}' {reason}",
820 entry.source
821 );
822 }
823 return match &parsed {
824 ParsedSource::Npm { spec, .. } => {
825 self.emit_progress(ProgressEvent {
826 event_type: ProgressEventType::Start,
827 action: ProgressAction::Update,
828 source: entry.source.clone(),
829 message: Some(format!("Updating {}...", name)),
830 });
831 let result = self.install_npm_pack(spec, entry.scope);
832 match &result {
833 Ok(_) => self.emit_progress(ProgressEvent {
834 event_type: ProgressEventType::Complete,
835 action: ProgressAction::Update,
836 source: entry.source.clone(),
837 message: None,
838 }),
839 Err(e) => self.emit_progress(ProgressEvent {
840 event_type: ProgressEventType::Error,
841 action: ProgressAction::Update,
842 source: entry.source.clone(),
843 message: Some(e.to_string()),
844 }),
845 }
846 result
847 }
848 ParsedSource::Git { repo, ref_, .. } => {
849 let target_dir = match &parsed {
850 ParsedSource::Git { host, path, .. } => {
851 self.git_install_path(host, path, entry.scope)
852 }
853 _ => unreachable!(),
854 };
855 if target_dir.exists() {
856 let updated = git_update(&target_dir, ref_.as_deref())?;
857 if updated && target_dir.join(NPM_MANIFEST_NAME).exists() {
858 let _ = std::process::Command::new("npm")
859 .args(["install", "--omit=dev"])
860 .current_dir(&target_dir)
861 .output();
862 }
863 self.load_manifest_from_dir(&target_dir, &entry.source, entry.scope)
864 } else {
865 self.install_git_sync(&entry.source, repo, ref_.as_deref(), entry.scope)
866 }
867 }
868 ParsedSource::Local { path } => self.install_local(path),
869 ParsedSource::Url { url } => {
870 run_on_fresh_runtime(self.install_url(url, entry.scope))
871 }
872 };
873 }
874
875 if self.installed.contains_key(name) {
877 self.install_npm_pack(name, SourceScope::User)
878 } else {
879 bail!("Package '{}' is not installed", name);
880 }
881 }
882
883 pub fn update_all(&mut self) -> Vec<(String, Result<PackageManifest>)> {
885 let names: Vec<String> = self.installed.keys().cloned().collect();
886 let mut results = Vec::new();
887 for name in names {
888 let result = self.update(&name);
889 results.push((name, result));
890 }
891 results
892 }
893
894 pub async fn check_for_updates(&self) -> Vec<PackageUpdateInfo> {
896 let mut updates = Vec::new();
897
898 for lock_entry in self.lockfile.packages.values() {
899 let parsed = ParsedSource::parse(&lock_entry.source);
900
901 match &parsed {
902 ParsedSource::Npm { name: pkg_name, .. } => {
903 match NpmPackageInfo::fetch(pkg_name).await {
905 Ok(info) => {
906 if let Some(latest) = info.latest_version()
907 && latest != lock_entry.version
908 {
909 updates.push(PackageUpdateInfo {
910 source: lock_entry.source.clone(),
911 display_name: pkg_name.clone(),
912 source_type: "npm".to_string(),
913 scope: lock_entry.scope,
914 });
915 }
916 }
917 Err(_) => continue,
918 }
919 }
920 ParsedSource::Git { host, path, .. } => {
921 let install_path = self.git_install_path(host, path, lock_entry.scope);
922 if install_path.exists() {
923 match git_has_update(&install_path) {
924 Ok(true) => {
925 updates.push(PackageUpdateInfo {
926 source: lock_entry.source.clone(),
927 display_name: format!("{}/{}", host, path),
928 source_type: "git".to_string(),
929 scope: lock_entry.scope,
930 });
931 }
932 _ => continue,
933 }
934 }
935 }
936 _ => continue,
937 }
938 }
939
940 updates
941 }
942
943 pub fn list(&self) -> Vec<&PackageManifest> {
947 self.installed.values().collect()
948 }
949
950 pub fn list_configured(&self) -> Vec<ConfiguredPackage> {
952 let mut result = Vec::new();
953 for name in self.installed.keys() {
954 let installed_path = self.get_install_dir(name);
955 let lock_entry = self.lockfile.get(name);
956 result.push(ConfiguredPackage {
957 source: lock_entry
958 .map(|e| e.source.clone())
959 .unwrap_or_else(|| name.clone()),
960 scope: lock_entry.map(|e| e.scope).unwrap_or(SourceScope::User),
961 filtered: false,
962 installed_path,
963 });
964 }
965 result
966 }
967
968 pub fn is_installed(&self, name: &str) -> bool {
970 self.installed.contains_key(name)
971 }
972
973 pub fn get_install_dir(&self, name: &str) -> Option<PathBuf> {
975 let dir = self.pkg_install_dir(name);
976 if dir.exists() { Some(dir) } else { None }
977 }
978
979 pub fn get_installed_path_for_source(
981 &self,
982 source: &str,
983 scope: SourceScope,
984 ) -> Option<PathBuf> {
985 let parsed = ParsedSource::parse(source);
986 match &parsed {
987 ParsedSource::Npm { name, .. } => {
988 let path = self.npm_install_path(name, scope);
989 if path.exists() { Some(path) } else { None }
990 }
991 ParsedSource::Git { host, path, .. } => {
992 let path = self.git_install_path(host, path, scope);
993 if path.exists() { Some(path) } else { None }
994 }
995 ParsedSource::Local { path } => {
996 let p = PathBuf::from(path);
997 if p.exists() { Some(p) } else { None }
998 }
999 ParsedSource::Url { .. } => None,
1000 }
1001 }
1002
1003 pub fn discover_resources(&self, name: &str) -> Result<Vec<DiscoveredResource>> {
1007 let manifest = self
1008 .installed
1009 .get(name)
1010 .with_context(|| format!("Package '{}' not found", name))?;
1011
1012 let install_dir = self.pkg_install_dir(name);
1013 if !install_dir.exists() {
1014 bail!("Install directory for '{}' does not exist", name);
1015 }
1016
1017 let mut resources = Vec::new();
1018
1019 let has_explicit = !manifest.extensions.is_empty()
1020 || !manifest.skills.is_empty()
1021 || !manifest.prompts.is_empty()
1022 || !manifest.themes.is_empty();
1023
1024 if has_explicit {
1025 for ext in &manifest.extensions {
1026 let path = install_dir.join(ext);
1027 if path.exists() {
1028 resources.push(DiscoveredResource {
1029 kind: ResourceKind::Extension,
1030 path,
1031 relative_path: ext.clone(),
1032 });
1033 }
1034 }
1035 for skill in &manifest.skills {
1036 let path = install_dir.join(skill);
1037 if path.exists() {
1038 resources.push(DiscoveredResource {
1039 kind: ResourceKind::Skill,
1040 path,
1041 relative_path: skill.clone(),
1042 });
1043 }
1044 }
1045 for prompt in &manifest.prompts {
1046 let path = install_dir.join(prompt);
1047 if path.exists() {
1048 resources.push(DiscoveredResource {
1049 kind: ResourceKind::Prompt,
1050 path,
1051 relative_path: prompt.clone(),
1052 });
1053 }
1054 }
1055 for theme in &manifest.themes {
1056 let path = install_dir.join(theme);
1057 if path.exists() {
1058 resources.push(DiscoveredResource {
1059 kind: ResourceKind::Theme,
1060 path,
1061 relative_path: theme.clone(),
1062 });
1063 }
1064 }
1065 } else {
1066 resources.extend(discover_extensions(&install_dir));
1067 resources.extend(discover_skills(&install_dir));
1068 resources.extend(discover_prompts(&install_dir));
1069 resources.extend(discover_themes(&install_dir));
1070 }
1071
1072 Ok(resources)
1073 }
1074
1075 pub fn resource_counts(&self, name: &str) -> Result<ResourceCounts> {
1077 let resources = self.discover_resources(name)?;
1078 let mut counts = ResourceCounts::default();
1079 for r in &resources {
1080 match r.kind {
1081 ResourceKind::Extension => counts.extensions += 1,
1082 ResourceKind::Skill => counts.skills += 1,
1083 ResourceKind::Prompt => counts.prompts += 1,
1084 ResourceKind::Theme => counts.themes += 1,
1085 }
1086 }
1087 Ok(counts)
1088 }
1089
1090 pub fn resolve(&self) -> ResolvedPaths {
1096 self.resolve_inner(None, None)
1097 }
1098
1099 pub fn resolve_with_config(
1104 &self,
1105 runtime_path: Option<&Path>,
1106 overrides_path: Option<&Path>,
1107 ) -> ResolvedPaths {
1108 let runtime = runtime_path.and_then(|p| {
1109 let pp = p.to_path_buf();
1110 super::runtime_config::RuntimeConfig::read(&pp).ok()
1111 });
1112 let overrides =
1113 overrides_path.and_then(|p| super::overrides::ProjectPluginOverrides::read(p).ok());
1114 self.resolve_inner(runtime.as_ref(), overrides.as_ref())
1115 }
1116
1117 pub fn runtime_config_path(&self) -> PathBuf {
1121 match dirs::home_dir() {
1124 Some(h) => h
1125 .join(".oxicode")
1126 .join(super::runtime_config::RUNTIME_CONFIG_FILE),
1127 None => self
1128 .packages_dir
1129 .join("..")
1130 .join(super::runtime_config::RUNTIME_CONFIG_FILE),
1131 }
1132 }
1133
1134 pub fn project_overrides_path(&self) -> PathBuf {
1137 super::overrides::ProjectPluginOverrides::project_path(&self.project_dir)
1138 }
1139
1140 fn resolve_inner(
1145 &self,
1146 runtime: Option<&super::runtime_config::RuntimeConfig>,
1147 overrides: Option<&super::overrides::ProjectPluginOverrides>,
1148 ) -> ResolvedPaths {
1149 let mut extensions = Vec::new();
1150 let mut skills = Vec::new();
1151 let mut prompts = Vec::new();
1152 let mut themes = Vec::new();
1153
1154 for name in self.installed.keys() {
1155 let install_dir = self.pkg_install_dir(name);
1156 if !install_dir.exists() {
1157 continue;
1158 }
1159
1160 let metadata = PathMetadata {
1161 source: name.clone(),
1162 scope: self
1163 .lockfile
1164 .get(name)
1165 .map(|e| e.scope)
1166 .unwrap_or(SourceScope::User),
1167 origin: ResourceOrigin::Package,
1168 base_dir: Some(install_dir.clone()),
1169 };
1170
1171 if let Ok(resources) = self.discover_resources(name) {
1172 for r in resources {
1173 let enabled =
1174 super::overrides::resolve_enabled(name, r.kind, overrides, runtime);
1175 let entry = ResolvedResource {
1176 path: r.path,
1177 enabled,
1178 metadata: metadata.clone(),
1179 };
1180 match r.kind {
1181 ResourceKind::Extension => extensions.push(entry),
1182 ResourceKind::Skill => skills.push(entry),
1183 ResourceKind::Prompt => prompts.push(entry),
1184 ResourceKind::Theme => themes.push(entry),
1185 }
1186 }
1187 }
1188 }
1189
1190 ResolvedPaths {
1191 extensions,
1192 skills,
1193 prompts,
1194 themes,
1195 }
1196 }
1197
1198 pub(crate) fn read_manifest_for_doctor(path: &Path) -> Result<PackageManifest> {
1202 Self::read_manifest(path)
1203 }
1204
1205 pub fn resolve_dependencies(&self) -> Vec<(String, Vec<String>)> {
1210 let mut result = Vec::new();
1211 let installed_names: HashSet<&str> = self.installed.keys().map(|s| s.as_str()).collect();
1212
1213 for (name, manifest) in &self.installed {
1214 let missing: Vec<String> = manifest
1215 .dependencies
1216 .keys()
1217 .filter(|dep| !installed_names.contains(dep.as_str()))
1218 .cloned()
1219 .collect();
1220
1221 if !missing.is_empty() {
1222 result.push((name.clone(), missing));
1223 }
1224 }
1225
1226 result
1227 }
1228
1229 pub fn validate_package(dir: &Path) -> Result<Vec<String>> {
1231 let mut warnings = Vec::new();
1232
1233 if !dir.join(MANIFEST_NAME).exists() && !dir.join(NPM_MANIFEST_NAME).exists() {
1235 warnings.push(format!(
1236 "No {} or {} found",
1237 MANIFEST_NAME, NPM_MANIFEST_NAME
1238 ));
1239 }
1240
1241 if dir.join(MANIFEST_NAME).exists() {
1243 match Self::read_manifest(&dir.join(MANIFEST_NAME)) {
1244 Ok(m) => {
1245 if m.name.is_empty() {
1246 warnings.push("Package name is empty".to_string());
1247 }
1248 if m.version.is_empty() {
1249 warnings.push("Package version is empty".to_string());
1250 }
1251 if semver::Version::parse(&m.version).is_err() {
1252 warnings.push(format!("Version '{}' is not valid semver", m.version));
1253 }
1254 let has_resources = !m.extensions.is_empty()
1255 || !m.skills.is_empty()
1256 || !m.prompts.is_empty()
1257 || !m.themes.is_empty();
1258 if !has_resources {
1259 let discovered = discover_extensions(dir)
1261 .into_iter()
1262 .chain(discover_skills(dir))
1263 .chain(discover_prompts(dir))
1264 .chain(discover_themes(dir))
1265 .count();
1266 if discovered == 0 {
1267 warnings.push(
1268 "Package has no explicit resources and auto-discovery found nothing"
1269 .to_string(),
1270 );
1271 }
1272 }
1273
1274 for ext in &m.extensions {
1276 if !dir.join(ext).exists() {
1277 warnings.push(format!("Extension path '{}' does not exist", ext));
1278 }
1279 }
1280 for skill in &m.skills {
1281 if !dir.join(skill).exists() {
1282 warnings.push(format!("Skill path '{}' does not exist", skill));
1283 }
1284 }
1285 for prompt in &m.prompts {
1286 if !dir.join(prompt).exists() {
1287 warnings.push(format!("Prompt path '{}' does not exist", prompt));
1288 }
1289 }
1290 for theme in &m.themes {
1291 if !dir.join(theme).exists() {
1292 warnings.push(format!("Theme path '{}' does not exist", theme));
1293 }
1294 }
1295 }
1296 Err(e) => {
1297 warnings.push(format!("Failed to parse {}: {}", MANIFEST_NAME, e));
1298 }
1299 }
1300 }
1301
1302 if !dir.join(".gitignore").exists() && !dir.join(".ignore").exists() {
1304 warnings.push("No .gitignore or .ignore file found".to_string());
1305 }
1306
1307 Ok(warnings)
1308 }
1309
1310 pub fn get_installed_version(&self, name: &str) -> Option<&str> {
1314 self.installed.get(name).map(|m| m.version.as_str())
1315 }
1316
1317 pub fn version_satisfies(&self, name: &str, requirement: &str) -> bool {
1319 if let Some(version) = self.get_installed_version(name)
1320 && let Ok(v) = semver::Version::parse(version)
1321 && let Ok(req) = semver::VersionReq::parse(requirement)
1322 {
1323 return req.matches(&v);
1324 }
1325 false
1326 }
1327
1328 pub fn lockfile(&self) -> &Lockfile {
1330 &self.lockfile
1331 }
1332}
1333
1334fn run_on_fresh_runtime<F, T>(future: F) -> Result<T>
1342where
1343 F: Future<Output = Result<T>> + Send,
1344 T: Send,
1345{
1346 std::thread::scope(|s| {
1347 s.spawn(|| {
1348 let rt = tokio::runtime::Builder::new_current_thread()
1349 .enable_all()
1350 .build()
1351 .context("failed to build temp runtime")?;
1352 rt.block_on(future)
1353 })
1354 .join()
1355 .map_err(|_| anyhow::anyhow!("runtime thread panicked"))?
1356 })
1357}
1358
1359#[cfg(test)]
1360mod tests {
1361 use super::super::lockfile::{
1362 compute_dir_hash as _compute_dir_hash,
1363 verify_lockfile_integrity as _verify_lockfile_integrity,
1364 };
1365 use super::*;
1366 use std::path::PathBuf;
1367
1368 fn setup_temp_packages_dir() -> (tempfile::TempDir, PathBuf) {
1369 let tmp = tempfile::tempdir().unwrap();
1370 let packages_dir = tmp.path().join("packages");
1371 fs::create_dir_all(&packages_dir).unwrap();
1372 (tmp, packages_dir)
1373 }
1374
1375 fn create_test_package(base: &Path, name: &str, version: &str) -> PathBuf {
1376 let pkg_dir = base.join("source-pkg");
1377 fs::create_dir_all(&pkg_dir).unwrap();
1378
1379 let manifest = PackageManifest {
1380 name: name.to_string(),
1381 version: version.to_string(),
1382 extensions: vec!["ext1.so".to_string()],
1383 skills: vec!["skill-a".to_string()],
1384 prompts: vec![],
1385 themes: vec![],
1386 description: None,
1387 dependencies: BTreeMap::new(),
1388 };
1389
1390 let toml_content = toml::to_string_pretty(&manifest).unwrap();
1391 fs::write(pkg_dir.join(MANIFEST_NAME), toml_content).unwrap();
1392 fs::write(pkg_dir.join("ext1.so"), "fake extension").unwrap();
1393 fs::create_dir_all(pkg_dir.join("skill-a")).unwrap();
1394 fs::write(pkg_dir.join("skill-a").join("SKILL.md"), "# Skill A").unwrap();
1395
1396 pkg_dir
1397 }
1398
1399 fn create_test_package_with_auto_discovery(base: &Path, name: &str, version: &str) -> PathBuf {
1400 let pkg_dir = base.join("source-pkg-auto");
1401 fs::create_dir_all(&pkg_dir).unwrap();
1402
1403 let manifest = PackageManifest {
1404 name: name.to_string(),
1405 version: version.to_string(),
1406 extensions: vec![],
1407 skills: vec![],
1408 prompts: vec![],
1409 themes: vec![],
1410 description: None,
1411 dependencies: BTreeMap::new(),
1412 };
1413 let toml_content = toml::to_string_pretty(&manifest).unwrap();
1414 fs::write(pkg_dir.join(MANIFEST_NAME), toml_content).unwrap();
1415
1416 fs::write(pkg_dir.join("myext.so"), "extension").unwrap();
1417 fs::create_dir_all(pkg_dir.join("my-skill")).unwrap();
1418 fs::write(pkg_dir.join("my-skill").join("SKILL.md"), "# My Skill").unwrap();
1419 fs::create_dir_all(pkg_dir.join("prompts")).unwrap();
1420 fs::write(pkg_dir.join("prompts").join("review.md"), "# Review").unwrap();
1421 fs::create_dir_all(pkg_dir.join("themes")).unwrap();
1422 fs::write(pkg_dir.join("themes").join("dark.json"), "{}").unwrap();
1423
1424 pkg_dir
1425 }
1426
1427 #[test]
1428 fn test_install_and_list() {
1429 let (tmp, packages_dir) = setup_temp_packages_dir();
1430
1431 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
1432 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1433
1434 let manifest = mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1435 assert_eq!(manifest.name, "test-pkg");
1436 assert_eq!(manifest.version, "1.0.0");
1437
1438 let installed = mgr.list();
1439 assert_eq!(installed.len(), 1);
1440 assert_eq!(installed[0].name, "test-pkg");
1441 }
1442
1443 #[test]
1444 fn test_uninstall() {
1445 let (tmp, packages_dir) = setup_temp_packages_dir();
1446
1447 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
1448 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1449
1450 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1451 assert!(mgr.is_installed("test-pkg"));
1452
1453 mgr.uninstall("test-pkg").unwrap();
1454 assert!(!mgr.is_installed("test-pkg"));
1455 assert!(mgr.list().is_empty());
1456 }
1457
1458 #[test]
1459 fn test_uninstall_not_installed() {
1460 let (_tmp, packages_dir) = setup_temp_packages_dir();
1461 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1462
1463 let result = mgr.uninstall("nonexistent");
1464 assert!(result.is_err());
1465 }
1466
1467 #[test]
1468 fn test_install_scoped_package() {
1469 let (tmp, packages_dir) = setup_temp_packages_dir();
1470
1471 let pkg_dir = create_test_package(tmp.path(), "@foo/oxicode-tools", "2.0.0");
1472 let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
1473
1474 let manifest = mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1475 assert_eq!(manifest.name, "@foo/oxicode-tools");
1476
1477 let expected_dir = packages_dir.join("foo-oxicode-tools");
1478 assert!(expected_dir.exists());
1479 }
1480
1481 #[test]
1482 fn test_reinstall_overwrites() {
1483 let (tmp, packages_dir) = setup_temp_packages_dir();
1484
1485 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
1486 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1487
1488 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1489
1490 let pkg_dir_v2 = tmp.path().join("source-pkg-v2");
1491 fs::create_dir_all(&pkg_dir_v2).unwrap();
1492 let manifest_v2 = PackageManifest {
1493 name: "test-pkg".to_string(),
1494 version: "2.0.0".to_string(),
1495 extensions: vec![],
1496 skills: vec![],
1497 prompts: vec![],
1498 themes: vec![],
1499 description: None,
1500 dependencies: BTreeMap::new(),
1501 };
1502 fs::write(
1503 pkg_dir_v2.join(MANIFEST_NAME),
1504 toml::to_string_pretty(&manifest_v2).unwrap(),
1505 )
1506 .unwrap();
1507
1508 mgr.install(pkg_dir_v2.to_str().unwrap()).unwrap();
1509
1510 let installed = mgr.list();
1511 assert_eq!(installed.len(), 1);
1512 assert_eq!(installed[0].version, "2.0.0");
1513 }
1514
1515 #[test]
1516 fn test_empty_packages_dir() {
1517 let (_tmp, packages_dir) = setup_temp_packages_dir();
1518 let mgr = PackageManager::with_dir(packages_dir).unwrap();
1519 assert!(mgr.list().is_empty());
1520 }
1521
1522 #[test]
1523 fn test_packages_dir_not_exists() {
1524 let tmp = tempfile::tempdir().unwrap();
1525 let nonexistent = tmp.path().join("does-not-exist");
1526 let mgr = PackageManager::with_dir(nonexistent).unwrap();
1527 assert!(mgr.list().is_empty());
1528 }
1529
1530 #[test]
1531 fn test_discover_resources_explicit() {
1532 let (tmp, packages_dir) = setup_temp_packages_dir();
1533
1534 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
1535 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1536 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1537
1538 let resources = mgr.discover_resources("test-pkg").unwrap();
1539 assert_eq!(resources.len(), 2);
1540
1541 let extensions: Vec<_> = resources
1542 .iter()
1543 .filter(|r| r.kind == ResourceKind::Extension)
1544 .collect();
1545 let skills: Vec<_> = resources
1546 .iter()
1547 .filter(|r| r.kind == ResourceKind::Skill)
1548 .collect();
1549 assert_eq!(extensions.len(), 1);
1550 assert_eq!(skills.len(), 1);
1551 }
1552
1553 #[test]
1554 fn test_discover_resources_auto() {
1555 let (tmp, packages_dir) = setup_temp_packages_dir();
1556
1557 let pkg_dir = create_test_package_with_auto_discovery(tmp.path(), "auto-pkg", "1.0.0");
1558 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1559 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1560
1561 let resources = mgr.discover_resources("auto-pkg").unwrap();
1562
1563 let ext_count = resources
1564 .iter()
1565 .filter(|r| r.kind == ResourceKind::Extension)
1566 .count();
1567 let skill_count = resources
1568 .iter()
1569 .filter(|r| r.kind == ResourceKind::Skill)
1570 .count();
1571 let prompt_count = resources
1572 .iter()
1573 .filter(|r| r.kind == ResourceKind::Prompt)
1574 .count();
1575 let theme_count = resources
1576 .iter()
1577 .filter(|r| r.kind == ResourceKind::Theme)
1578 .count();
1579
1580 assert!(
1581 ext_count >= 1,
1582 "Expected at least 1 extension, got {}",
1583 ext_count
1584 );
1585 assert!(
1586 skill_count >= 1,
1587 "Expected at least 1 skill, got {}",
1588 skill_count
1589 );
1590 assert!(
1591 prompt_count >= 1,
1592 "Expected at least 1 prompt, got {}",
1593 prompt_count
1594 );
1595 assert!(
1596 theme_count >= 1,
1597 "Expected at least 1 theme, got {}",
1598 theme_count
1599 );
1600 }
1601
1602 #[test]
1603 fn test_resource_counts() {
1604 let (tmp, packages_dir) = setup_temp_packages_dir();
1605
1606 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
1607 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1608 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1609
1610 let counts = mgr.resource_counts("test-pkg").unwrap();
1611 assert_eq!(counts.extensions, 1);
1612 assert_eq!(counts.skills, 1);
1613 assert_eq!(counts.prompts, 0);
1614 assert_eq!(counts.themes, 0);
1615 }
1616
1617 #[test]
1618 fn test_resource_counts_display() {
1619 let counts = ResourceCounts {
1620 extensions: 2,
1621 skills: 1,
1622 prompts: 0,
1623 themes: 3,
1624 };
1625 assert_eq!(counts.to_string(), "2 ext, 1 skill, 3 theme");
1626
1627 let empty = ResourceCounts::default();
1628 assert_eq!(empty.to_string(), "-");
1629 }
1630
1631 #[test]
1632 fn test_resource_kind_display() {
1633 assert_eq!(ResourceKind::Extension.to_string(), "extension");
1634 assert_eq!(ResourceKind::Skill.to_string(), "skill");
1635 assert_eq!(ResourceKind::Prompt.to_string(), "prompt");
1636 assert_eq!(ResourceKind::Theme.to_string(), "theme");
1637 }
1638
1639 #[test]
1640 fn test_get_install_dir() {
1641 let (tmp, packages_dir) = setup_temp_packages_dir();
1642
1643 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
1644 let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
1645 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1646
1647 let dir = mgr.get_install_dir("test-pkg").unwrap();
1648 assert!(dir.exists());
1649 assert!(dir.join(MANIFEST_NAME).exists());
1650
1651 assert!(mgr.get_install_dir("nonexistent").is_none());
1652 }
1653
1654 #[test]
1655 fn test_discover_resources_not_installed() {
1656 let (_tmp, packages_dir) = setup_temp_packages_dir();
1657 let mgr = PackageManager::with_dir(packages_dir).unwrap();
1658
1659 let result = mgr.discover_resources("nonexistent");
1660 assert!(result.is_err());
1661 }
1662
1663 #[test]
1664 fn test_update_not_installed() {
1665 let (_tmp, packages_dir) = setup_temp_packages_dir();
1666 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1667
1668 let result = mgr.update("nonexistent");
1669 assert!(result.is_err());
1670 }
1671
1672 #[test]
1675 fn test_parse_npm_source() {
1676 let parsed = ParsedSource::parse("npm:express@4.18.0");
1677 match parsed {
1678 ParsedSource::Npm { spec, name, pinned } => {
1679 assert_eq!(spec, "express@4.18.0");
1680 assert_eq!(name, "express");
1681 assert!(pinned);
1682 }
1683 _ => panic!("Expected Npm source"),
1684 }
1685
1686 let parsed = ParsedSource::parse("npm:lodash");
1687 match parsed {
1688 ParsedSource::Npm { name, pinned, .. } => {
1689 assert_eq!(name, "lodash");
1690 assert!(!pinned);
1691 }
1692 _ => panic!("Expected Npm source"),
1693 }
1694 }
1695
1696 #[test]
1697 fn test_parse_git_source() {
1698 let parsed = ParsedSource::parse("https://github.com/org/repo.git");
1699 match parsed {
1700 ParsedSource::Git {
1701 host, path, ref_, ..
1702 } => {
1703 assert_eq!(host, "github.com");
1704 assert_eq!(path, "org/repo");
1705 assert!(ref_.is_none());
1706 }
1707 _ => panic!("Expected Git source"),
1708 }
1709
1710 let parsed = ParsedSource::parse("https://github.com/org/repo.git@v1.0.0");
1711 match parsed {
1712 ParsedSource::Git { path, ref_, .. } => {
1713 assert_eq!(path, "org/repo");
1714 assert_eq!(ref_.as_deref(), Some("v1.0.0"));
1715 }
1716 _ => panic!("Expected Git source"),
1717 }
1718 }
1719
1720 #[test]
1721 fn test_parse_github_shorthand() {
1722 let parsed = ParsedSource::parse("github:org/repo@main");
1723 match parsed {
1724 ParsedSource::Git {
1725 host, path, ref_, ..
1726 } => {
1727 assert_eq!(host, "github.com");
1728 assert_eq!(path, "org/repo");
1729 assert_eq!(ref_.as_deref(), Some("main"));
1730 }
1731 _ => panic!("Expected Git source"),
1732 }
1733 }
1734
1735 #[test]
1736 fn test_parse_local_source() {
1737 let parsed = ParsedSource::parse("/path/to/package");
1738 match parsed {
1739 ParsedSource::Local { path } => {
1740 assert_eq!(path, "/path/to/package");
1741 }
1742 _ => panic!("Expected Local source"),
1743 }
1744
1745 let parsed = ParsedSource::parse("./relative/path");
1746 match parsed {
1747 ParsedSource::Local { path } => {
1748 assert_eq!(path, "./relative/path");
1749 }
1750 _ => panic!("Expected Local source"),
1751 }
1752 }
1753
1754 #[test]
1755 fn test_parse_url_source() {
1756 let parsed = ParsedSource::parse("https://example.com/pkg.tar.gz");
1757 match parsed {
1758 ParsedSource::Url { url } => {
1759 assert_eq!(url, "https://example.com/pkg.tar.gz");
1760 }
1761 _ => panic!("Expected Url source"),
1762 }
1763 }
1764
1765 #[test]
1766 fn test_source_identity() {
1767 let npm = ParsedSource::parse("npm:express@4.18.0");
1768 assert_eq!(npm.identity(), "npm:express");
1769
1770 let git = ParsedSource::parse("https://github.com/org/repo.git");
1771 assert_eq!(git.identity(), "git:github.com/org/repo");
1772
1773 let local = ParsedSource::parse("/path/to/pkg");
1774 assert_eq!(local.identity(), "local:/path/to/pkg");
1775 }
1776
1777 #[test]
1778 fn test_parse_npm_spec() {
1779 let (name, pinned) = parse_npm_spec("express@4.18.0");
1780 assert_eq!(name, "express");
1781 assert!(pinned);
1782
1783 let (name, pinned) = parse_npm_spec("express");
1784 assert_eq!(name, "express");
1785 assert!(!pinned);
1786
1787 let (name, pinned) = parse_npm_spec("@scope/pkg@1.0.0");
1788 assert_eq!(name, "@scope/pkg");
1789 assert!(pinned);
1790 }
1791
1792 #[test]
1795 fn test_lockfile_roundtrip() {
1796 let (tmp, _) = setup_temp_packages_dir();
1797 let lock_path = tmp.path().join(LOCKFILE_NAME);
1798
1799 let mut lock = Lockfile::new();
1800 lock.insert(LockEntry {
1801 source: "npm:express@4.18.0".to_string(),
1802 name: "express".to_string(),
1803 version: "4.18.0".to_string(),
1804 integrity: Some("sha256-abc123".to_string()),
1805 scope: SourceScope::User,
1806 source_type: "npm".to_string(),
1807 dependencies: BTreeMap::new(),
1808 });
1809
1810 lock.write(&lock_path).unwrap();
1811
1812 let loaded = Lockfile::read(&lock_path).unwrap().unwrap();
1813 assert_eq!(loaded.packages.len(), 1);
1814 assert_eq!(loaded.packages["express"].version, "4.18.0");
1815 assert_eq!(
1816 loaded.packages["express"].integrity.as_deref(),
1817 Some("sha256-abc123")
1818 );
1819 }
1820
1821 #[test]
1822 fn test_lockfile_install_roundtrip() {
1823 let (tmp, packages_dir) = setup_temp_packages_dir();
1824 let pkg_dir = create_test_package(tmp.path(), "locked-pkg", "1.0.0");
1825
1826 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1827 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1828
1829 let lock_path = mgr.packages_dir().join(LOCKFILE_NAME);
1831 assert!(lock_path.exists());
1832
1833 let lock = Lockfile::read(&lock_path).unwrap().unwrap();
1834 assert!(lock.contains("locked-pkg"));
1835 let entry = lock.get("locked-pkg").unwrap();
1836 assert_eq!(entry.version, "1.0.0");
1837 }
1838
1839 #[test]
1842 fn test_validate_valid_package() {
1843 let (tmp, _) = setup_temp_packages_dir();
1844 let pkg_dir = create_test_package(tmp.path(), "valid-pkg", "1.0.0");
1845 let warnings = PackageManager::validate_package(&pkg_dir).unwrap();
1846 assert!(
1848 warnings.len() <= 1,
1849 "Expected <= 1 warning, got {:?}",
1850 warnings
1851 );
1852 }
1853
1854 #[test]
1855 fn test_validate_empty_dir() {
1856 let tmp = tempfile::tempdir().unwrap();
1857 let empty_dir = tmp.path().join("empty-pkg");
1858 fs::create_dir_all(&empty_dir).unwrap();
1859 let warnings = PackageManager::validate_package(&empty_dir).unwrap();
1860 assert!(!warnings.is_empty());
1861 }
1862
1863 #[test]
1866 fn test_resolve_dependencies() {
1867 let (tmp, packages_dir) = setup_temp_packages_dir();
1868
1869 let pkg_dir = tmp.path().join("dep-pkg");
1871 fs::create_dir_all(&pkg_dir).unwrap();
1872 let mut deps = BTreeMap::new();
1873 deps.insert("lodash".to_string(), "^4.0.0".to_string());
1874 deps.insert("nonexistent-pkg".to_string(), "^1.0.0".to_string());
1875
1876 let manifest = PackageManifest {
1877 name: "dep-pkg".to_string(),
1878 version: "1.0.0".to_string(),
1879 extensions: vec![],
1880 skills: vec![],
1881 prompts: vec![],
1882 themes: vec![],
1883 description: None,
1884 dependencies: deps,
1885 };
1886 fs::write(
1887 pkg_dir.join(MANIFEST_NAME),
1888 toml::to_string_pretty(&manifest).unwrap(),
1889 )
1890 .unwrap();
1891
1892 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1893 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1894
1895 let missing = mgr.resolve_dependencies();
1896 assert_eq!(missing.len(), 1);
1897 assert_eq!(missing[0].0, "dep-pkg");
1898 assert!(
1899 missing[0].1.contains(&"lodash".to_string())
1900 || missing[0].1.contains(&"nonexistent-pkg".to_string())
1901 );
1902 }
1903
1904 #[test]
1907 fn test_version_satisfies() {
1908 let (tmp, packages_dir) = setup_temp_packages_dir();
1909 let pkg_dir = create_test_package(tmp.path(), "ver-pkg", "1.2.3");
1910 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1911 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1912
1913 assert!(mgr.version_satisfies("ver-pkg", "^1.0.0"));
1914 assert!(mgr.version_satisfies("ver-pkg", ">=1.0.0"));
1915 assert!(!mgr.version_satisfies("ver-pkg", "^2.0.0"));
1916 assert!(!mgr.version_satisfies("ver-pkg", "<1.0.0"));
1917 }
1918
1919 #[test]
1920 fn test_get_installed_version() {
1921 let (tmp, packages_dir) = setup_temp_packages_dir();
1922 let pkg_dir = create_test_package(tmp.path(), "ver-pkg", "3.1.4");
1923 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1924 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1925
1926 assert_eq!(mgr.get_installed_version("ver-pkg"), Some("3.1.4"));
1927 assert_eq!(mgr.get_installed_version("nonexistent"), None);
1928 }
1929
1930 #[test]
1933 fn test_resolve() {
1934 let (tmp, packages_dir) = setup_temp_packages_dir();
1935 let pkg_dir = create_test_package(tmp.path(), "resolve-pkg", "1.0.0");
1936 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1937 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1938
1939 let resolved = mgr.resolve();
1940 assert!(!resolved.extensions.is_empty() || !resolved.skills.is_empty());
1941 }
1942
1943 #[test]
1946 fn test_progress_callback() {
1947 use std::sync::{Arc, Mutex};
1948
1949 let events: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
1950 let events_clone = events.clone();
1951
1952 let (tmp, packages_dir) = setup_temp_packages_dir();
1953 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1954
1955 mgr.set_progress_callback(Box::new(move |event| {
1956 let mut e = events_clone.lock().unwrap();
1957 e.push(format!("{:?}:{:?}", event.event_type, event.action));
1958 }));
1959
1960 let pkg_dir = create_test_package(tmp.path(), "progress-pkg", "1.0.0");
1961 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1962
1963 let _event_count = events.lock().unwrap().len();
1966 }
1967
1968 #[test]
1969 fn test_list_configured() {
1970 let (tmp, packages_dir) = setup_temp_packages_dir();
1971 let pkg_dir = create_test_package(tmp.path(), "cfg-pkg", "1.0.0");
1972 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1973 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1974
1975 let configured = mgr.list_configured();
1976 assert_eq!(configured.len(), 1);
1977 assert!(configured[0].source.contains("source-pkg"));
1978 }
1980
1981 #[test]
1986 fn verify_lockfile_integrity_accepts_matching_dir() {
1987 let tmp = tempfile::tempdir().unwrap();
1988 let pkg_dir = tmp.path().join("pkg");
1989 fs::create_dir_all(&pkg_dir).unwrap();
1990 fs::write(pkg_dir.join("a.txt"), b"hello").unwrap();
1991 fs::write(pkg_dir.join("b.txt"), b"world").unwrap();
1992
1993 let expected = _compute_dir_hash(&pkg_dir).expect("compute_dir_hash must succeed");
1994
1995 assert!(_verify_lockfile_integrity(&pkg_dir, &expected).is_ok());
1996 }
1997
1998 #[test]
2003 fn verify_lockfile_integrity_rejects_tampered_dir() {
2004 let tmp = tempfile::tempdir().unwrap();
2005 let pkg_dir = tmp.path().join("pkg");
2006 fs::create_dir_all(&pkg_dir).unwrap();
2007 fs::write(pkg_dir.join("a.txt"), b"hello").unwrap();
2008
2009 let expected = _compute_dir_hash(&pkg_dir).expect("compute_dir_hash must succeed");
2010
2011 fs::write(pkg_dir.join("a.txt"), b"tampered").unwrap();
2013
2014 let err = _verify_lockfile_integrity(&pkg_dir, &expected)
2015 .expect_err("tampered dir must not verify");
2016 assert!(err.contains("sha256 mismatch"), "unexpected error: {err}");
2017 }
2018
2019 #[test]
2021 fn verify_lockfile_integrity_rejects_bad_prefix() {
2022 let tmp = tempfile::tempdir().unwrap();
2023 let pkg_dir = tmp.path().join("pkg");
2024 fs::create_dir_all(&pkg_dir).unwrap();
2025
2026 let err = _verify_lockfile_integrity(&pkg_dir, "abc123")
2027 .expect_err("missing sha256- prefix must be rejected");
2028 assert!(err.contains("not in `sha256-<hex>` form"));
2029 }
2030
2031 #[test]
2034 fn load_installed_skips_tampered_package() {
2035 let (tmp, packages_dir) = setup_temp_packages_dir();
2036 let pkg_dir = create_test_package(tmp.path(), "tamper-pkg", "1.0.0");
2037
2038 {
2040 let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
2041 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2042 }
2043
2044 let installed_name = "tamper-pkg";
2046 let installed_safe = installed_name.replace('@', "").replace('/', "-");
2047 let on_disk = packages_dir.join(installed_safe);
2048 fs::write(on_disk.join(MANIFEST_NAME), "tampered = true\n").unwrap();
2049
2050 let mgr2 = PackageManager::with_dir(packages_dir).unwrap();
2052 let names: Vec<&str> = mgr2.list().iter().map(|m| m.name.as_str()).collect();
2053 assert!(
2054 !names.contains(&"tamper-pkg"),
2055 "tampered package must be excluded from load_installed; loaded names: {names:?}"
2056 );
2057 }
2058}