1use anyhow::{Context, Result};
29use serde::{Deserialize, Serialize};
30use std::collections::HashMap;
31use std::fs;
32use std::path::{Path, PathBuf};
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct PackageManifest {
37 pub name: String,
39 pub version: String,
41 #[serde(default)]
43 pub extensions: Vec<String>,
44 #[serde(default)]
46 pub skills: Vec<String>,
47 #[serde(default)]
49 pub prompts: Vec<String>,
50 #[serde(default)]
52 pub themes: Vec<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct DiscoveredResource {
58 pub kind: ResourceKind,
60 pub path: PathBuf,
62 pub relative_path: String,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum ResourceKind {
70 Extension,
71 Skill,
72 Prompt,
73 Theme,
74}
75
76impl std::fmt::Display for ResourceKind {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 match self {
79 ResourceKind::Extension => write!(f, "extension"),
80 ResourceKind::Skill => write!(f, "skill"),
81 ResourceKind::Prompt => write!(f, "prompt"),
82 ResourceKind::Theme => write!(f, "theme"),
83 }
84 }
85}
86
87pub struct PackageManager {
89 packages_dir: PathBuf,
90 installed: HashMap<String, PackageManifest>,
91}
92
93impl PackageManager {
94 pub fn new() -> Result<Self> {
96 let base = dirs::home_dir().context("Cannot determine home directory")?;
97 let packages_dir = base.join(".oxi").join("packages");
98 let mut mgr = Self {
99 packages_dir,
100 installed: HashMap::new(),
101 };
102 mgr.load_installed()?;
103 Ok(mgr)
104 }
105
106 pub fn with_dir(packages_dir: PathBuf) -> Result<Self> {
108 let mut mgr = Self {
109 packages_dir,
110 installed: HashMap::new(),
111 };
112 mgr.load_installed()?;
113 Ok(mgr)
114 }
115
116 fn load_installed(&mut self) -> Result<()> {
118 if !self.packages_dir.exists() {
119 return Ok(());
120 }
121 for entry in fs::read_dir(&self.packages_dir)? {
122 let entry = entry?;
123 let manifest_path = entry.path().join("oxi-package.toml");
124 if manifest_path.exists() {
125 match Self::read_manifest(&manifest_path) {
126 Ok(manifest) => {
127 self.installed.insert(manifest.name.clone(), manifest);
128 }
129 Err(e) => {
130 tracing::warn!("Failed to load manifest {}: {}", manifest_path.display(), e);
131 }
132 }
133 }
134 }
135 Ok(())
136 }
137
138 fn read_manifest(path: &Path) -> Result<PackageManifest> {
140 let content = fs::read_to_string(path)
141 .with_context(|| format!("Failed to read manifest {}", path.display()))?;
142 let manifest: PackageManifest = toml::from_str(&content)
143 .with_context(|| format!("Failed to parse manifest {}", path.display()))?;
144 Ok(manifest)
145 }
146
147 fn pkg_install_dir(&self, name: &str) -> PathBuf {
149 let safe_name = name.replace('@', "").replace('/', "-");
151 self.packages_dir.join(safe_name)
152 }
153
154 pub fn install(&mut self, source: &str) -> Result<PackageManifest> {
156 let source_path = Path::new(source);
157 let manifest_path = source_path.join("oxi-package.toml");
158
159 let manifest = Self::read_manifest(&manifest_path)
160 .with_context(|| format!("No valid oxi-package.toml found in {}", source))?;
161
162 let dest = self.pkg_install_dir(&manifest.name);
163
164 fs::create_dir_all(&self.packages_dir)
166 .with_context(|| format!("Failed to create packages directory {}", self.packages_dir.display()))?;
167
168 if dest.exists() {
170 fs::remove_dir_all(&dest)
171 .with_context(|| format!("Failed to remove existing package at {}", dest.display()))?;
172 }
173
174 copy_dir_recursive(source_path, &dest)
176 .with_context(|| format!("Failed to copy package from {} to {}", source, dest.display()))?;
177
178 self.installed.insert(manifest.name.clone(), manifest.clone());
179 Ok(manifest)
180 }
181
182 pub fn install_npm(&mut self, name: &str) -> Result<PackageManifest> {
184 let tmp_dir = tempfile::tempdir()
186 .context("Failed to create temp directory for npm install")?;
187
188 let status = std::process::Command::new("npm")
189 .args(["pack", name, "--pack-destination"])
190 .arg(tmp_dir.path())
191 .current_dir(tmp_dir.path())
192 .output()
193 .context("Failed to run npm pack")?;
194
195 if !status.status.success() {
196 let stderr = String::from_utf8_lossy(&status.stderr);
197 anyhow::bail!("npm pack failed for '{}': {}", name, stderr);
198 }
199
200 let tarball = fs::read_dir(tmp_dir.path())?
202 .filter_map(|e| e.ok())
203 .find(|e| {
204 e.path()
205 .extension()
206 .map(|ext| ext == "tgz")
207 .unwrap_or(false)
208 })
209 .map(|e| e.path())
210 .context("No .tgz file found after npm pack")?;
211
212 let extract_dir = tmp_dir.path().join("extracted");
214 fs::create_dir_all(&extract_dir)?;
215
216 let tar_status = std::process::Command::new("tar")
217 .args(["-xzf", &tarball.to_string_lossy(), "-C"])
218 .arg(&extract_dir)
219 .current_dir(tmp_dir.path())
220 .output()
221 .context("Failed to run tar")?;
222
223 if !tar_status.status.success() {
224 let stderr = String::from_utf8_lossy(&tar_status.stderr);
225 anyhow::bail!("tar extraction failed: {}", stderr);
226 }
227
228 let pkg_source = extract_dir.join("package");
230
231 fs::create_dir_all(&self.packages_dir)
233 .with_context(|| format!("Failed to create packages directory {}", self.packages_dir.display()))?;
234
235 let safe_name = name.replace('@', "").replace('/', "-");
236 let dest = self.packages_dir.join(safe_name);
237
238 if dest.exists() {
239 fs::remove_dir_all(&dest)
240 .with_context(|| format!("Failed to remove existing package at {}", dest.display()))?;
241 }
242
243 copy_dir_recursive(&pkg_source, &dest)
244 .with_context(|| format!("Failed to copy npm package for '{}'", name))?;
245
246 let manifest_path = dest.join("oxi-package.toml");
248 let manifest = if manifest_path.exists() {
249 Self::read_manifest(&manifest_path)?
250 } else {
251 PackageManifest {
253 name: name.to_string(),
254 version: "0.0.0".to_string(),
255 extensions: Vec::new(),
256 skills: Vec::new(),
257 prompts: Vec::new(),
258 themes: Vec::new(),
259 }
260 };
261
262 self.installed.insert(manifest.name.clone(), manifest.clone());
263 Ok(manifest)
264 }
265
266 pub fn uninstall(&mut self, name: &str) -> Result<()> {
268 if !self.installed.contains_key(name) {
269 anyhow::bail!("Package '{}' is not installed", name);
270 }
271
272 let dest = self.pkg_install_dir(name);
273 if dest.exists() {
274 fs::remove_dir_all(&dest)
275 .with_context(|| format!("Failed to remove package directory {}", dest.display()))?;
276 }
277
278 self.installed.remove(name);
279 Ok(())
280 }
281
282 pub fn update(&mut self, name: &str) -> Result<PackageManifest> {
286 if !self.installed.contains_key(name) {
287 anyhow::bail!("Package '{}' is not installed", name);
288 }
289
290 let _manifest = self.installed.get(name).cloned().unwrap();
293
294 let result = self.install_npm(name)?;
296 Ok(result)
297 }
298
299 pub fn list(&self) -> Vec<&PackageManifest> {
301 self.installed.values().collect()
302 }
303
304 pub fn is_installed(&self, name: &str) -> bool {
306 self.installed.contains_key(name)
307 }
308
309 pub fn packages_dir(&self) -> &Path {
311 &self.packages_dir
312 }
313
314 pub fn get_install_dir(&self, name: &str) -> Option<PathBuf> {
316 let dir = self.pkg_install_dir(name);
317 if dir.exists() {
318 Some(dir)
319 } else {
320 None
321 }
322 }
323
324 pub fn discover_resources(&self, name: &str) -> Result<Vec<DiscoveredResource>> {
329 let manifest = self.installed.get(name)
330 .with_context(|| format!("Package '{}' not found", name))?;
331
332 let install_dir = self.pkg_install_dir(name);
333 if !install_dir.exists() {
334 anyhow::bail!("Install directory for '{}' does not exist", name);
335 }
336
337 let mut resources = Vec::new();
338
339 let has_explicit = !manifest.extensions.is_empty()
341 || !manifest.skills.is_empty()
342 || !manifest.prompts.is_empty()
343 || !manifest.themes.is_empty();
344
345 if has_explicit {
346 for ext in &manifest.extensions {
347 let path = install_dir.join(ext);
348 if path.exists() {
349 resources.push(DiscoveredResource {
350 kind: ResourceKind::Extension,
351 path,
352 relative_path: ext.clone(),
353 });
354 }
355 }
356 for skill in &manifest.skills {
357 let path = install_dir.join(skill);
358 if path.exists() {
359 resources.push(DiscoveredResource {
360 kind: ResourceKind::Skill,
361 path,
362 relative_path: skill.clone(),
363 });
364 }
365 }
366 for prompt in &manifest.prompts {
367 let path = install_dir.join(prompt);
368 if path.exists() {
369 resources.push(DiscoveredResource {
370 kind: ResourceKind::Prompt,
371 path,
372 relative_path: prompt.clone(),
373 });
374 }
375 }
376 for theme in &manifest.themes {
377 let path = install_dir.join(theme);
378 if path.exists() {
379 resources.push(DiscoveredResource {
380 kind: ResourceKind::Theme,
381 path,
382 relative_path: theme.clone(),
383 });
384 }
385 }
386 } else {
387 resources.extend(discover_extensions(&install_dir));
389 resources.extend(discover_skills(&install_dir));
390 resources.extend(discover_prompts(&install_dir));
391 resources.extend(discover_themes(&install_dir));
392 }
393
394 Ok(resources)
395 }
396
397 pub fn resource_counts(&self, name: &str) -> Result<ResourceCounts> {
399 let resources = self.discover_resources(name)?;
400 let mut counts = ResourceCounts::default();
401 for r in &resources {
402 match r.kind {
403 ResourceKind::Extension => counts.extensions += 1,
404 ResourceKind::Skill => counts.skills += 1,
405 ResourceKind::Prompt => counts.prompts += 1,
406 ResourceKind::Theme => counts.themes += 1,
407 }
408 }
409 Ok(counts)
410 }
411}
412
413#[derive(Debug, Clone, Default, Serialize, Deserialize)]
415pub struct ResourceCounts {
416 pub extensions: usize,
417 pub skills: usize,
418 pub prompts: usize,
419 pub themes: usize,
420}
421
422impl std::fmt::Display for ResourceCounts {
423 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
424 let mut parts = Vec::new();
425 if self.extensions > 0 {
426 parts.push(format!("{} ext", self.extensions));
427 }
428 if self.skills > 0 {
429 parts.push(format!("{} skill", self.skills));
430 }
431 if self.prompts > 0 {
432 parts.push(format!("{} prompt", self.prompts));
433 }
434 if self.themes > 0 {
435 parts.push(format!("{} theme", self.themes));
436 }
437 if parts.is_empty() {
438 write!(f, "-")?;
439 } else {
440 write!(f, "{}", parts.join(", "))?;
441 }
442 Ok(())
443 }
444}
445
446fn discover_extensions(dir: &Path) -> Vec<DiscoveredResource> {
454 let mut results = Vec::new();
455 discover_extensions_recursive(dir, dir, &mut results);
456 results
457}
458
459fn discover_extensions_recursive(base: &Path, current: &Path, results: &mut Vec<DiscoveredResource>) {
460 if !current.exists() {
461 return;
462 }
463
464 let entries = match fs::read_dir(current) {
465 Ok(e) => e,
466 Err(_) => return,
467 };
468
469 for entry in entries.flatten() {
470 let path = entry.path();
471 let name = entry.file_name();
472 let name_str = name.to_string_lossy();
473
474 if name_str.starts_with('.') || name_str == "node_modules" {
476 continue;
477 }
478
479 if path.is_dir() {
480 for index in &["index.ts", "index.js"] {
482 let index_path = path.join(index);
483 if index_path.exists() {
484 let rel = path.strip_prefix(base).unwrap_or(&path);
485 results.push(DiscoveredResource {
486 kind: ResourceKind::Extension,
487 path: index_path,
488 relative_path: rel.join(index).to_string_lossy().to_string(),
489 });
490 }
491 }
492 } else {
494 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
496 if matches!(ext, "so" | "dylib" | "dll") {
497 let rel = path.strip_prefix(base).unwrap_or(&path);
498 results.push(DiscoveredResource {
499 kind: ResourceKind::Extension,
500 path: path.clone(),
501 relative_path: rel.to_string_lossy().to_string(),
502 });
503 }
504 if matches!(ext, "ts" | "js") && !name_str.starts_with('.') {
506 let rel = path.strip_prefix(base).unwrap_or(&path);
507 results.push(DiscoveredResource {
508 kind: ResourceKind::Extension,
509 path: path.clone(),
510 relative_path: rel.to_string_lossy().to_string(),
511 });
512 }
513 }
514 }
515}
516
517fn discover_skills(dir: &Path) -> Vec<DiscoveredResource> {
519 let mut results = Vec::new();
520 let entries = match fs::read_dir(dir) {
521 Ok(e) => e,
522 Err(_) => return results,
523 };
524
525 for entry in entries.flatten() {
526 let path = entry.path();
527 let name = entry.file_name();
528 let name_str = name.to_string_lossy();
529
530 if name_str.starts_with('.') || name_str == "node_modules" {
531 continue;
532 }
533
534 if path.is_dir() {
535 let skill_file = path.join("SKILL.md");
536 if skill_file.exists() {
537 let rel = path.strip_prefix(dir).unwrap_or(&path);
538 results.push(DiscoveredResource {
539 kind: ResourceKind::Skill,
540 path: skill_file,
541 relative_path: rel.join("SKILL.md").to_string_lossy().to_string(),
542 });
543 }
544
545 let skills_subdir = dir.join("skills");
547 if skills_subdir.exists() && skills_subdir.is_dir() {
548 let sub_entries = match fs::read_dir(&skills_subdir) {
549 Ok(e) => e,
550 Err(_) => continue,
551 };
552 for sub_entry in sub_entries.flatten() {
553 let sub_path = sub_entry.path();
554 if sub_path.is_dir() {
555 let sf = sub_path.join("SKILL.md");
556 if sf.exists() {
557 let rel = sub_path.strip_prefix(dir).unwrap_or(&sub_path);
558 results.push(DiscoveredResource {
559 kind: ResourceKind::Skill,
560 path: sf,
561 relative_path: rel.join("SKILL.md").to_string_lossy().to_string(),
562 });
563 }
564 }
565 }
566 }
567 }
568 }
569 results
570}
571
572fn discover_prompts(dir: &Path) -> Vec<DiscoveredResource> {
574 let prompts_dir = dir.join("prompts");
575 discover_files_by_ext(
576 if prompts_dir.exists() { &prompts_dir } else { dir },
577 "md",
578 ResourceKind::Prompt,
579 )
580}
581
582fn discover_themes(dir: &Path) -> Vec<DiscoveredResource> {
584 let themes_dir = dir.join("themes");
585 discover_files_by_ext(
586 if themes_dir.exists() { &themes_dir } else { dir },
587 "json",
588 ResourceKind::Theme,
589 )
590}
591
592fn discover_files_by_ext(dir: &Path, ext: &str, kind: ResourceKind) -> Vec<DiscoveredResource> {
594 let mut results = Vec::new();
595 discover_files_recursive(dir, dir, ext, kind, &mut results);
596 results
597}
598
599fn discover_files_recursive(
600 base: &Path,
601 current: &Path,
602 ext: &str,
603 kind: ResourceKind,
604 results: &mut Vec<DiscoveredResource>,
605) {
606 if !current.exists() {
607 return;
608 }
609
610 let entries = match fs::read_dir(current) {
611 Ok(e) => e,
612 Err(_) => return,
613 };
614
615 for entry in entries.flatten() {
616 let path = entry.path();
617 let name = entry.file_name();
618 let name_str = name.to_string_lossy();
619
620 if name_str.starts_with('.') || name_str == "node_modules" {
621 continue;
622 }
623
624 if path.is_dir() {
625 discover_files_recursive(base, &path, ext, kind, results);
626 } else if path.extension().and_then(|e| e.to_str()) == Some(ext) {
627 let rel = path.strip_prefix(base).unwrap_or(&path);
628 results.push(DiscoveredResource {
629 kind,
630 path: path.clone(),
631 relative_path: rel.to_string_lossy().to_string(),
632 });
633 }
634 }
635}
636
637fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
639 if !dst.exists() {
640 fs::create_dir_all(dst)?;
641 }
642
643 for entry in fs::read_dir(src)? {
644 let entry = entry?;
645 let src_path = entry.path();
646 let dst_path = dst.join(entry.file_name());
647
648 if src_path.is_dir() {
649 copy_dir_recursive(&src_path, &dst_path)?;
650 } else {
651 fs::copy(&src_path, &dst_path)?;
652 }
653 }
654
655 Ok(())
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661
662 fn setup_temp_packages_dir() -> (tempfile::TempDir, PathBuf) {
663 let tmp = tempfile::tempdir().unwrap();
664 let packages_dir = tmp.path().join("packages");
665 fs::create_dir_all(&packages_dir).unwrap();
666 (tmp, packages_dir)
667 }
668
669 fn create_test_package(base: &Path, name: &str, version: &str) -> PathBuf {
670 let pkg_dir = base.join("source-pkg");
671 fs::create_dir_all(&pkg_dir).unwrap();
672
673 let manifest = PackageManifest {
674 name: name.to_string(),
675 version: version.to_string(),
676 extensions: vec!["ext1.so".to_string()],
677 skills: vec!["skill-a".to_string()],
678 prompts: vec![],
679 themes: vec![],
680 };
681
682 let toml_content = toml::to_string_pretty(&manifest).unwrap();
683 fs::write(pkg_dir.join("oxi-package.toml"), toml_content).unwrap();
684 fs::write(pkg_dir.join("ext1.so"), "fake extension").unwrap();
685 fs::create_dir_all(pkg_dir.join("skill-a")).unwrap();
686 fs::write(pkg_dir.join("skill-a").join("SKILL.md"), "# Skill A").unwrap();
687
688 pkg_dir
689 }
690
691 fn create_test_package_with_auto_discovery(base: &Path, name: &str, version: &str) -> PathBuf {
692 let pkg_dir = base.join("source-pkg-auto");
693 fs::create_dir_all(&pkg_dir).unwrap();
694
695 let manifest = PackageManifest {
697 name: name.to_string(),
698 version: version.to_string(),
699 extensions: vec![],
700 skills: vec![],
701 prompts: vec![],
702 themes: vec![],
703 };
704 let toml_content = toml::to_string_pretty(&manifest).unwrap();
705 fs::write(pkg_dir.join("oxi-package.toml"), toml_content).unwrap();
706
707 fs::write(pkg_dir.join("myext.so"), "extension").unwrap();
709 fs::create_dir_all(pkg_dir.join("my-skill")).unwrap();
710 fs::write(pkg_dir.join("my-skill").join("SKILL.md"), "# My Skill").unwrap();
711 fs::create_dir_all(pkg_dir.join("prompts")).unwrap();
712 fs::write(pkg_dir.join("prompts").join("review.md"), "# Review").unwrap();
713 fs::create_dir_all(pkg_dir.join("themes")).unwrap();
714 fs::write(pkg_dir.join("themes").join("dark.json"), "{}").unwrap();
715
716 pkg_dir
717 }
718
719 #[test]
720 fn test_install_and_list() {
721 let (tmp, packages_dir) = setup_temp_packages_dir();
722
723 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
724 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
725
726 let manifest = mgr.install(pkg_dir.to_str().unwrap()).unwrap();
727 assert_eq!(manifest.name, "test-pkg");
728 assert_eq!(manifest.version, "1.0.0");
729
730 let installed = mgr.list();
731 assert_eq!(installed.len(), 1);
732 assert_eq!(installed[0].name, "test-pkg");
733 }
734
735 #[test]
736 fn test_uninstall() {
737 let (tmp, packages_dir) = setup_temp_packages_dir();
738
739 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
740 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
741
742 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
743 assert!(mgr.is_installed("test-pkg"));
744
745 mgr.uninstall("test-pkg").unwrap();
746 assert!(!mgr.is_installed("test-pkg"));
747 assert!(mgr.list().is_empty());
748 }
749
750 #[test]
751 fn test_uninstall_not_installed() {
752 let (_tmp, packages_dir) = setup_temp_packages_dir();
753 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
754
755 let result = mgr.uninstall("nonexistent");
756 assert!(result.is_err());
757 }
758
759 #[test]
760 fn test_install_scoped_package() {
761 let (tmp, packages_dir) = setup_temp_packages_dir();
762
763 let pkg_dir = create_test_package(tmp.path(), "@foo/oxi-tools", "2.0.0");
764 let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
765
766 let manifest = mgr.install(pkg_dir.to_str().unwrap()).unwrap();
767 assert_eq!(manifest.name, "@foo/oxi-tools");
768
769 let expected_dir = packages_dir.join("foo-oxi-tools");
771 assert!(expected_dir.exists());
772 }
773
774 #[test]
775 fn test_reinstall_overwrites() {
776 let (tmp, packages_dir) = setup_temp_packages_dir();
777
778 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
779 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
780
781 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
782
783 let pkg_dir_v2 = tmp.path().join("source-pkg-v2");
785 fs::create_dir_all(&pkg_dir_v2).unwrap();
786 let manifest_v2 = PackageManifest {
787 name: "test-pkg".to_string(),
788 version: "2.0.0".to_string(),
789 extensions: vec![],
790 skills: vec![],
791 prompts: vec![],
792 themes: vec![],
793 };
794 fs::write(
795 pkg_dir_v2.join("oxi-package.toml"),
796 toml::to_string_pretty(&manifest_v2).unwrap(),
797 )
798 .unwrap();
799
800 mgr.install(pkg_dir_v2.to_str().unwrap()).unwrap();
801
802 let installed = mgr.list();
803 assert_eq!(installed.len(), 1);
804 assert_eq!(installed[0].version, "2.0.0");
805 }
806
807 #[test]
808 fn test_empty_packages_dir() {
809 let (_tmp, packages_dir) = setup_temp_packages_dir();
810 let mgr = PackageManager::with_dir(packages_dir).unwrap();
811 assert!(mgr.list().is_empty());
812 }
813
814 #[test]
815 fn test_packages_dir_not_exists() {
816 let tmp = tempfile::tempdir().unwrap();
817 let nonexistent = tmp.path().join("does-not-exist");
818 let mgr = PackageManager::with_dir(nonexistent).unwrap();
819 assert!(mgr.list().is_empty());
820 }
821
822 #[test]
823 fn test_discover_resources_explicit() {
824 let (tmp, packages_dir) = setup_temp_packages_dir();
825
826 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
827 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
828 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
829
830 let resources = mgr.discover_resources("test-pkg").unwrap();
831 assert_eq!(resources.len(), 2); let extensions: Vec<_> = resources.iter().filter(|r| r.kind == ResourceKind::Extension).collect();
834 let skills: Vec<_> = resources.iter().filter(|r| r.kind == ResourceKind::Skill).collect();
835 assert_eq!(extensions.len(), 1);
836 assert_eq!(skills.len(), 1);
837 }
838
839 #[test]
840 fn test_discover_resources_auto() {
841 let (tmp, packages_dir) = setup_temp_packages_dir();
842
843 let pkg_dir = create_test_package_with_auto_discovery(tmp.path(), "auto-pkg", "1.0.0");
844 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
845 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
846
847 let resources = mgr.discover_resources("auto-pkg").unwrap();
848
849 let ext_count = resources.iter().filter(|r| r.kind == ResourceKind::Extension).count();
851 let skill_count = resources.iter().filter(|r| r.kind == ResourceKind::Skill).count();
852 let prompt_count = resources.iter().filter(|r| r.kind == ResourceKind::Prompt).count();
853 let theme_count = resources.iter().filter(|r| r.kind == ResourceKind::Theme).count();
854
855 assert!(ext_count >= 1, "Expected at least 1 extension, got {}", ext_count);
856 assert!(skill_count >= 1, "Expected at least 1 skill, got {}", skill_count);
857 assert!(prompt_count >= 1, "Expected at least 1 prompt, got {}", prompt_count);
858 assert!(theme_count >= 1, "Expected at least 1 theme, got {}", theme_count);
859 }
860
861 #[test]
862 fn test_resource_counts() {
863 let (tmp, packages_dir) = setup_temp_packages_dir();
864
865 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
866 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
867 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
868
869 let counts = mgr.resource_counts("test-pkg").unwrap();
870 assert_eq!(counts.extensions, 1);
871 assert_eq!(counts.skills, 1);
872 assert_eq!(counts.prompts, 0);
873 assert_eq!(counts.themes, 0);
874 }
875
876 #[test]
877 fn test_resource_counts_display() {
878 let counts = ResourceCounts {
879 extensions: 2,
880 skills: 1,
881 prompts: 0,
882 themes: 3,
883 };
884 assert_eq!(counts.to_string(), "2 ext, 1 skill, 3 theme");
885
886 let empty = ResourceCounts::default();
887 assert_eq!(empty.to_string(), "-");
888 }
889
890 #[test]
891 fn test_resource_kind_display() {
892 assert_eq!(ResourceKind::Extension.to_string(), "extension");
893 assert_eq!(ResourceKind::Skill.to_string(), "skill");
894 assert_eq!(ResourceKind::Prompt.to_string(), "prompt");
895 assert_eq!(ResourceKind::Theme.to_string(), "theme");
896 }
897
898 #[test]
899 fn test_get_install_dir() {
900 let (tmp, packages_dir) = setup_temp_packages_dir();
901
902 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
903 let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
904 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
905
906 let dir = mgr.get_install_dir("test-pkg").unwrap();
907 assert!(dir.exists());
908 assert!(dir.join("oxi-package.toml").exists());
909
910 assert!(mgr.get_install_dir("nonexistent").is_none());
911 }
912
913 #[test]
914 fn test_discover_resources_not_installed() {
915 let (_tmp, packages_dir) = setup_temp_packages_dir();
916 let mgr = PackageManager::with_dir(packages_dir).unwrap();
917
918 let result = mgr.discover_resources("nonexistent");
919 assert!(result.is_err());
920 }
921
922 #[test]
923 fn test_update_not_installed() {
924 let (_tmp, packages_dir) = setup_temp_packages_dir();
925 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
926
927 let result = mgr.update("nonexistent");
928 assert!(result.is_err());
929 }
930}