Skip to main content

oxi/
packages.rs

1//! Package system for oxi CLI
2//!
3//! Packages bundle extensions, skills, prompts, and themes for sharing.
4//! Supports local directories and npm packages.
5//!
6//! ## Package manifest
7//!
8//! A package is a directory containing an `oxi-package.toml` file:
9//!
10//! ```toml
11//! name = "@foo/oxi-tools"
12//! version = "1.0.0"
13//! extensions = ["ext/index.ts"]
14//! skills = ["skills/code-review/SKILL.md"]
15//! prompts = ["prompts/review.md"]
16//! themes = ["themes/dark-pro.json"]
17//! ```
18//!
19//! ## Resource discovery
20//!
21//! When a package lacks explicit resource lists, resources are discovered
22//! automatically by scanning the package directory:
23//! - **Extensions**: `.so`, `.dylib`, `.dll` files, or `index.ts`/`index.js` entries
24//! - **Skills**: Directories containing `SKILL.md`
25//! - **Prompts**: `.md` files in `prompts/` subdirectory
26//! - **Themes**: `.json` files in `themes/` subdirectory
27
28use anyhow::{Context, Result};
29use serde::{Deserialize, Serialize};
30use std::collections::HashMap;
31use std::fs;
32use std::path::{Path, PathBuf};
33
34/// Package manifest describing bundled resources
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct PackageManifest {
37    /// Package name (e.g. "@foo/oxi-tools")
38    pub name: String,
39    /// Semantic version (e.g. "1.0.0")
40    pub version: String,
41    /// Extension paths relative to the package root
42    #[serde(default)]
43    pub extensions: Vec<String>,
44    /// Skill names/paths
45    #[serde(default)]
46    pub skills: Vec<String>,
47    /// Prompt template paths
48    #[serde(default)]
49    pub prompts: Vec<String>,
50    /// Theme paths
51    #[serde(default)]
52    pub themes: Vec<String>,
53}
54
55/// A discovered resource within a package
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct DiscoveredResource {
58    /// Resource type
59    pub kind: ResourceKind,
60    /// Absolute path to the resource
61    pub path: PathBuf,
62    /// Relative path within the package
63    pub relative_path: String,
64}
65
66/// Types of resources a package can contribute
67#[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
87/// Manages installation, removal, and listing of packages
88pub struct PackageManager {
89    packages_dir: PathBuf,
90    installed: HashMap<String, PackageManifest>,
91}
92
93impl PackageManager {
94    /// Create a new PackageManager using the default packages directory
95    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    /// Create a PackageManager with a custom packages directory (for testing)
107    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    /// Load all installed package manifests from disk
117    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    /// Read and parse a package manifest from disk
139    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    /// Get the installation directory for a package
148    fn pkg_install_dir(&self, name: &str) -> PathBuf {
149        // Sanitise name: replace @ and / with -
150        let safe_name = name.replace('@', "").replace('/', "-");
151        self.packages_dir.join(safe_name)
152    }
153
154    /// Install a package from a local directory path
155    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        // Ensure packages directory exists
165        fs::create_dir_all(&self.packages_dir)
166            .with_context(|| format!("Failed to create packages directory {}", self.packages_dir.display()))?;
167
168        // Remove previous installation if it exists
169        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 the entire source directory
175        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    /// Install a package from npm
183    pub fn install_npm(&mut self, name: &str) -> Result<PackageManifest> {
184        // npm pack the package to a temp directory
185        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        // Find the tarball
201        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        // Extract tarball into a subdirectory
213        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        // npm pack extracts into a "package" subdirectory
229        let pkg_source = extract_dir.join("package");
230
231        // Ensure packages directory exists
232        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        // Read the manifest from the installed location
247        let manifest_path = dest.join("oxi-package.toml");
248        let manifest = if manifest_path.exists() {
249            Self::read_manifest(&manifest_path)?
250        } else {
251            // Synthesise a minimal manifest if the package doesn't have one
252            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    /// Uninstall a package by name
267    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    /// Update a package (re-install from the same source).
283    /// For npm packages, re-runs `npm pack` to get the latest version.
284    /// For local packages, re-copies from the source path (if available).
285    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        // For npm packages, re-run install_npm
291        // The package name IS the npm spec for npm packages
292        let _manifest = self.installed.get(name).cloned().unwrap();
293
294        // Re-install: try npm first (most common), fall back to no-op
295        let result = self.install_npm(name)?;
296        Ok(result)
297    }
298
299    /// List all installed packages
300    pub fn list(&self) -> Vec<&PackageManifest> {
301        self.installed.values().collect()
302    }
303
304    /// Check whether a package is installed
305    pub fn is_installed(&self, name: &str) -> bool {
306        self.installed.contains_key(name)
307    }
308
309    /// Get the packages directory path
310    pub fn packages_dir(&self) -> &Path {
311        &self.packages_dir
312    }
313
314    /// Get the install directory for a package (if it exists on disk)
315    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    /// Discover all resources from an installed package.
325    ///
326    /// If the manifest lists explicit paths, those are resolved against the
327    /// install directory. Otherwise, auto-discovery is performed.
328    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        // If manifest has explicit entries, use them
340        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            // Auto-discover resources
388            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    /// Get resource counts for a package
398    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/// Counts of each resource type in a package
414#[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
446// ── Auto-discovery helpers ────────────────────────────────────────────
447
448/// Discover extension files in a directory.
449///
450/// Looks for:
451/// - `.so`, `.dylib`, `.dll` files (compiled extensions)
452/// - `index.ts` or `index.js` files (TypeScript/JavaScript extensions)
453fn 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        // Skip hidden files and node_modules
475        if name_str.starts_with('.') || name_str == "node_modules" {
476            continue;
477        }
478
479        if path.is_dir() {
480            // Check for index.ts / index.js in subdirectory
481            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            // Don't recurse into extension directories — just check top-level
493        } else {
494            // Check for compiled extension files
495            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            // Check for top-level .ts/.js files
505            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
517/// Discover skill directories containing SKILL.md
518fn 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            // Also check skills/ subdirectory
546            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
572/// Discover prompt template files (.md in prompts/ subdirectory)
573fn 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
582/// Discover theme files (.json in themes/ subdirectory)
583fn 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
592/// Recursively find files with a given extension
593fn 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
637/// Recursively copy a directory
638fn 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        // Minimal manifest with no explicit resource lists
696        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        // Create auto-discoverable resources
708        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        // Install dir should use sanitised name
770        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        // Install again (same name, different version)
784        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); // ext1.so + skill-a/SKILL.md
832
833        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        // Should discover: myext.so, my-skill/SKILL.md, prompts/review.md, themes/dark.json
850        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}