Skip to main content

oxi/storage/
packages.rs

1//! Package system for oxi CLI
2//!
3//! Packages bundle extensions, skills, prompts, and themes for sharing.
4//! Supports local directories, npm packages, git repositories, GitHub
5//! shorthand, and URL-based archives.
6//!
7//! ## Package sources
8//!
9//! - **Local path**: a directory with `oxi-package.toml` or auto-discoverable resources
10//! - **npm**: `npm:<package>[@<version>]` — resolved from the npm registry
11//! - **git**: `https://github.com/org/repo.git[@ref]`, `git://…`, `git+ssh://…`
12//! - **GitHub shorthand**: `github:org/repo[@ref]`
13//! - **URL**: direct `.tar.gz` / `.zip` archive
14//!
15//! ## Package manifest
16//!
17//! A package is a directory containing an `oxi-package.toml` file:
18//!
19//! ```toml
20//! name = "@foo/oxi-tools"
21//! version = "1.0.0"
22//! extensions = ["ext/index.ts"]
23//! skills = ["skills/code-review/SKILL.md"]
24//! prompts = ["prompts/review.md"]
25//! themes = ["themes/dark-pro.json"]
26//! ```
27//!
28//! ## Resource discovery
29//!
30//! When a package lacks explicit resource lists, resources are discovered
31//! automatically by scanning the package directory:
32//! - **Extensions**: `.so`, `.dylib`, `.dll` files, or `index.ts`/`index.js` entries
33//! - **Skills**: Directories containing `SKILL.md`
34//! - **Prompts**: `.md` files in `prompts/` subdirectory
35//! - **Themes**: `.json` files in `themes/` subdirectory
36//!
37//! ## Lockfile
38//!
39//! An `oxi-lock.json` file records exact versions/refs for reproducibility.
40
41use crate::util::http_client::shared_http_client;
42use anyhow::{bail, Context, Result};
43use serde::{Deserialize, Serialize};
44use sha2::{Digest, Sha256};
45use std::collections::{BTreeMap, HashMap, HashSet};
46use std::fs;
47use std::path::{Path, PathBuf};
48use std::sync::LazyLock;
49
50/// Cached regex for parsing npm package specs
51static NPM_SPEC_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
52    regex::Regex::new(r"^(@?[^@]+(?:/[^@]+)?)(?:@(.+))?$").expect("valid static regex")
53});
54
55// ── Constants ─────────────────────────────────────────────────────────
56
57const LOCKFILE_NAME: &str = "oxi-lock.json";
58const MANIFEST_NAME: &str = "oxi-package.toml";
59const NPM_MANIFEST_NAME: &str = "package.json";
60
61// ── Types ─────────────────────────────────────────────────────────────
62
63/// Types of resources a package can contribute
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum ResourceKind {
67    /// extension variant.
68    Extension,
69    /// skill variant.
70    Skill,
71    /// prompt variant.
72    Prompt,
73    /// theme variant.
74    Theme,
75}
76
77impl std::fmt::Display for ResourceKind {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self {
80            ResourceKind::Extension => write!(f, "extension"),
81            ResourceKind::Skill => write!(f, "skill"),
82            ResourceKind::Prompt => write!(f, "prompt"),
83            ResourceKind::Theme => write!(f, "theme"),
84        }
85    }
86}
87
88// All resource kinds for iteration
89
90/// Package manifest describing bundled resources
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct PackageManifest {
93    /// Package name (e.g. "@foo/oxi-tools")
94    pub name: String,
95    /// Semantic version (e.g. "1.0.0")
96    pub version: String,
97    /// Extension paths relative to the package root
98    #[serde(default)]
99    pub extensions: Vec<String>,
100    /// Skill names/paths
101    #[serde(default)]
102    pub skills: Vec<String>,
103    /// Prompt template paths
104    #[serde(default)]
105    pub prompts: Vec<String>,
106    /// Theme paths
107    #[serde(default)]
108    pub themes: Vec<String>,
109    /// Optional description
110    #[serde(default)]
111    pub description: Option<String>,
112    /// Package dependencies (name -> version constraint)
113    #[serde(default)]
114    pub dependencies: BTreeMap<String, String>,
115}
116
117/// A discovered resource within a package
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct DiscoveredResource {
120    /// Resource type
121    pub kind: ResourceKind,
122    /// Absolute path to the resource
123    pub path: PathBuf,
124    /// Relative path within the package
125    pub relative_path: String,
126}
127
128/// Metadata about a resolved resource path
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct PathMetadata {
131    /// Source specifier
132    pub source: String,
133    /// Scope (user / project)
134    pub scope: SourceScope,
135    /// Whether this is a package resource or top-level
136    pub origin: ResourceOrigin,
137    /// Base directory for resolving relative paths
138    pub base_dir: Option<PathBuf>,
139}
140
141/// Origin of a resource
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "snake_case")]
144pub enum ResourceOrigin {
145    /// package variant.
146    Package,
147    /// top level variant.
148    TopLevel,
149}
150
151/// Scope for package sources
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
153#[serde(rename_all = "snake_case")]
154pub enum SourceScope {
155    /// user variant.
156    User,
157    /// project variant.
158    Project,
159}
160
161impl std::fmt::Display for SourceScope {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        match self {
164            SourceScope::User => write!(f, "user"),
165            SourceScope::Project => write!(f, "project"),
166        }
167    }
168}
169
170/// A resolved resource with metadata
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct ResolvedResource {
173    /// Absolute path to the resource
174    pub path: PathBuf,
175    /// Whether this resource is enabled
176    pub enabled: bool,
177    /// Metadata about the resource
178    pub metadata: PathMetadata,
179}
180
181/// Resolved paths for all resource types
182#[derive(Debug, Clone, Default, Serialize, Deserialize)]
183pub struct ResolvedPaths {
184    /// pub.
185    pub extensions: Vec<ResolvedResource>,
186    /// pub.
187    pub skills: Vec<ResolvedResource>,
188    /// pub.
189    pub prompts: Vec<ResolvedResource>,
190    /// pub.
191    pub themes: Vec<ResolvedResource>,
192}
193
194/// Progress events for package operations
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct ProgressEvent {
197    /// pub.
198    pub event_type: ProgressEventType,
199    /// pub.
200    pub action: ProgressAction,
201    /// pub.
202    pub source: String,
203    /// pub.
204    pub message: Option<String>,
205}
206
207/// Progress event type
208#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
209#[serde(rename_all = "snake_case")]
210pub enum ProgressEventType {
211    /// start variant.
212    Start,
213    /// progress variant.
214    Progress,
215    /// complete variant.
216    Complete,
217    /// error variant.
218    Error,
219}
220
221/// Action being performed
222#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum ProgressAction {
225    /// install variant.
226    Install,
227    /// remove variant.
228    Remove,
229    /// update variant.
230    Update,
231    /// clone variant.
232    Clone,
233    /// pull variant.
234    Pull,
235}
236
237impl std::fmt::Display for ProgressAction {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        match self {
240            ProgressAction::Install => write!(f, "install"),
241            ProgressAction::Remove => write!(f, "remove"),
242            ProgressAction::Update => write!(f, "update"),
243            ProgressAction::Clone => write!(f, "clone"),
244            ProgressAction::Pull => write!(f, "pull"),
245        }
246    }
247}
248
249/// Callback for progress events
250pub type ProgressCallback = Box<dyn Fn(ProgressEvent) + Send + Sync>;
251
252// ── Source parsing ────────────────────────────────────────────────────
253
254/// Parsed package source
255#[derive(Debug, Clone, Serialize, Deserialize)]
256#[serde(tag = "type", rename_all = "snake_case")]
257pub enum ParsedSource {
258    /// Variant.
259    Npm {
260        /// Full spec (e.g. "express@4.18.0")
261        spec: String,
262        /// Package name without version
263        name: String,
264        /// Whether a version was pinned
265        pinned: bool,
266    },
267    /// Variant.
268    Git {
269        /// Full repository URL
270        repo: String,
271        /// Host (e.g. "github.com")
272        host: String,
273        /// Path on host (e.g. "org/repo")
274        path: String,
275        /// Optional ref (branch / tag / commit)
276        ref_: Option<String>,
277    },
278    /// Variant.
279    Local {
280        /// Local path
281        path: String,
282    },
283    /// Variant.
284    Url {
285        /// URL to archive
286        url: String,
287    },
288}
289
290impl ParsedSource {
291    /// Parse a source string into a ParsedSource
292    pub fn parse(source: &str) -> Self {
293        if let Some(rest) = source.strip_prefix("npm:") {
294            let spec = rest.trim();
295            let (name, pinned) = parse_npm_spec(spec);
296            return ParsedSource::Npm {
297                spec: spec.to_string(),
298                name,
299                pinned,
300            };
301        }
302
303        if let Some(rest) = source.strip_prefix("github:") {
304            let parts: Vec<&str> = rest.splitn(2, '/').collect();
305            if parts.len() == 2 {
306                let (path, ref_) = split_git_path_ref(rest);
307                return ParsedSource::Git {
308                    repo: format!("https://github.com/{}.git", path),
309                    host: "github.com".to_string(),
310                    path,
311                    ref_,
312                };
313            }
314        }
315
316        if source.starts_with("git+") || source.starts_with("git://") || source.starts_with("git@")
317        {
318            return parse_git_source(source);
319        }
320
321        if source.starts_with("https://") || source.starts_with("http://") {
322            // Distinguish git URLs from plain archive URLs
323            if source.ends_with(".git")
324                || source.contains("github.com")
325                || source.contains("gitlab.com")
326            {
327                return parse_git_source(source);
328            }
329            // Archive URL (.tar.gz, .zip, .tgz)
330            if source.ends_with(".tar.gz")
331                || source.ends_with(".tgz")
332                || source.ends_with(".zip")
333                || source.ends_with(".tar.bz2")
334            {
335                return ParsedSource::Url {
336                    url: source.to_string(),
337                };
338            }
339            // Default to git for http(s) URLs that look like repos
340            return parse_git_source(source);
341        }
342
343        // Local path
344        ParsedSource::Local {
345            path: source.to_string(),
346        }
347    }
348
349    /// Get a unique identity key for this source (ignoring version/ref)
350    pub fn identity(&self) -> String {
351        match self {
352            ParsedSource::Npm { name, .. } => format!("npm:{}", name),
353            ParsedSource::Git { host, path, .. } => format!("git:{}/{}", host, path),
354            ParsedSource::Local { path } => format!("local:{}", path),
355            ParsedSource::Url { url } => format!("url:{}", url),
356        }
357    }
358
359    /// Get a display-friendly name
360    pub fn display_name(&self) -> String {
361        match self {
362            ParsedSource::Npm { name, .. } => name.clone(),
363            ParsedSource::Git { host, path, .. } => format!("{}/{}", host, path),
364            ParsedSource::Local { path } => path.clone(),
365            ParsedSource::Url { url } => url.clone(),
366        }
367    }
368}
369
370/// Parse an npm spec into (name, pinned)
371fn parse_npm_spec(spec: &str) -> (String, bool) {
372    // Handle scoped packages like @scope/name@version
373    if let Some(caps) = NPM_SPEC_RE.captures(spec) {
374        let name = caps.get(1).map(|m| m.as_str()).unwrap_or(spec);
375        let has_version = caps.get(2).is_some();
376        return (name.to_string(), has_version);
377    }
378    (spec.to_string(), false)
379}
380
381/// Split a git path like "org/repo@ref" into ("org/repo", Some("ref"))
382fn split_git_path_ref(input: &str) -> (String, Option<String>) {
383    if let Some(at_pos) = input.rfind('@') {
384        // Make sure it's not part of an email (don't split if there's no / before @)
385        if input[..at_pos].contains('/') {
386            return (
387                input[..at_pos].to_string(),
388                Some(input[at_pos + 1..].to_string()),
389            );
390        }
391    }
392    (input.to_string(), None)
393}
394
395/// Parse a git source string
396fn parse_git_source(source: &str) -> ParsedSource {
397    // Handle git@host:path format (SSH)
398    if let Some(rest) = source.strip_prefix("git@") {
399        let colon_pos = rest.find(':').unwrap_or(rest.len());
400        let host = &rest[..colon_pos];
401        let path_part = rest.get(colon_pos + 1..).unwrap_or("");
402        let (path, ref_) = if let Some(hash_pos) = path_part.rfind('#') {
403            (
404                path_part[..hash_pos].to_string(),
405                Some(path_part[hash_pos + 1..].to_string()),
406            )
407        } else {
408            split_git_path_ref(path_part)
409        };
410        let repo = format!("git@{}:{}", host, path_part);
411        let host = host.to_string();
412        return ParsedSource::Git {
413            repo,
414            host,
415            path: path.trim_end_matches(".git").to_string(),
416            ref_,
417        };
418    }
419
420    // Handle git+ssh://, git+https://, git:// prefixes
421    let url_str = source
422        .strip_prefix("git+")
423        .unwrap_or(source)
424        .strip_prefix("git://")
425        .map(|s| format!("https://{}", s))
426        .unwrap_or_else(|| source.strip_prefix("git+").unwrap_or(source).to_string());
427
428    // Parse URL to extract host and path
429    let url = match url::Url::parse(&url_str) {
430        Ok(u) => u,
431        Err(_) => {
432            return ParsedSource::Local {
433                path: source.to_string(),
434            }
435        }
436    };
437
438    let host = url.host_str().unwrap_or("unknown").to_string();
439    let full_path = url.path().trim_start_matches('/').to_string();
440
441    // Check for #ref fragment
442    let fragment = url.fragment().map(|f| f.to_string());
443
444    let (path, ref_) = if let Some(frag) = fragment {
445        (full_path.trim_end_matches(".git").to_string(), Some(frag))
446    } else {
447        let (p, r) = split_git_path_ref(&full_path);
448        (p.trim_end_matches(".git").to_string(), r)
449    };
450
451    let repo = url_str.clone();
452
453    ParsedSource::Git {
454        repo,
455        host,
456        path,
457        ref_,
458    }
459}
460
461// ── NPM Registry ──────────────────────────────────────────────────────
462
463/// Information fetched from the npm registry
464#[derive(Debug, Clone, Serialize, Deserialize)]
465pub struct NpmPackageInfo {
466    /// pub.
467    pub name: String,
468    /// pub.
469    pub versions: BTreeMap<String, serde_json::Value>,
470    /// pub.
471    #[serde(rename = "dist-tags")]
472    pub dist_tags: BTreeMap<String, String>,
473}
474
475impl NpmPackageInfo {
476    /// Fetch package info from the npm registry
477    pub async fn fetch(name: &str) -> Result<Self> {
478        let url = format!("https://registry.npmjs.org/{}", name);
479        let client = shared_http_client();
480
481        let resp = client
482            .get(&url)
483            .header("Accept", "application/json")
484            .send()
485            .await
486            .with_context(|| format!("Failed to fetch npm info for '{}'", name))?;
487
488        if !resp.status().is_success() {
489            bail!("npm registry returned {} for '{}'", resp.status(), name);
490        }
491
492        let info: NpmPackageInfo = resp
493            .json()
494            .await
495            .with_context(|| format!("Failed to parse npm registry response for '{}'", name))?;
496
497        Ok(info)
498    }
499
500    /// Get the latest version from dist-tags
501    pub fn latest_version(&self) -> Option<&str> {
502        self.dist_tags.get("latest").map(|s| s.as_str())
503    }
504
505    /// Find the best matching version for a constraint
506    pub fn resolve_version(&self, constraint: &str) -> Option<String> {
507        if constraint == "latest" || constraint.is_empty() {
508            return self.latest_version().map(|s| s.to_string());
509        }
510
511        // Try exact match first
512        if self.versions.contains_key(constraint) {
513            return Some(constraint.to_string());
514        }
515
516        // Try semver range matching
517        if let Ok(req) = semver::VersionReq::parse(constraint) {
518            let mut best: Option<semver::Version> = None;
519            for ver_str in self.versions.keys() {
520                if let Ok(ver) = semver::Version::parse(ver_str) {
521                    if req.matches(&ver) {
522                        match &best {
523                            Some(b) if ver > *b => best = Some(ver),
524                            None => best = Some(ver),
525                            _ => {}
526                        }
527                    }
528                }
529            }
530            if let Some(v) = best {
531                return Some(v.to_string());
532            }
533        }
534
535        None
536    }
537}
538
539/// Get the latest version of an npm package
540pub async fn get_latest_npm_version(name: &str) -> Result<String> {
541    let info = NpmPackageInfo::fetch(name).await?;
542    info.latest_version()
543        .map(|s| s.to_string())
544        .context(format!("No latest version found for '{}'", name))
545}
546
547// ── Git Operations ────────────────────────────────────────────────────
548
549/// Run a git command and capture stdout
550fn git_command(args: &[&str], cwd: Option<&Path>) -> Result<String> {
551    let mut cmd = std::process::Command::new("git");
552    cmd.args(args)
553        .env("GIT_TERMINAL_PROMPT", "0")
554        .stdout(std::process::Stdio::piped())
555        .stderr(std::process::Stdio::piped());
556
557    if let Some(dir) = cwd {
558        cmd.current_dir(dir);
559    }
560
561    let output = cmd.output().context("Failed to execute git")?;
562
563    if !output.status.success() {
564        let stderr = String::from_utf8_lossy(&output.stderr);
565        bail!(
566            "git {} failed ({}): {}",
567            args.join(" "),
568            output.status,
569            stderr.trim()
570        );
571    }
572
573    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
574}
575
576/// Run a git command (no capture)
577fn git_command_silent(args: &[&str], cwd: Option<&Path>) -> Result<()> {
578    let mut cmd = std::process::Command::new("git");
579    cmd.args(args)
580        .env("GIT_TERMINAL_PROMPT", "0")
581        .stdout(std::process::Stdio::null())
582        .stderr(std::process::Stdio::null());
583
584    if let Some(dir) = cwd {
585        cmd.current_dir(dir);
586    }
587
588    let status = cmd.status().context("Failed to execute git")?;
589    if !status.success() {
590        bail!("git {} failed ({})", args.join(" "), status);
591    }
592    Ok(())
593}
594
595/// Clone a git repository
596pub fn git_clone(repo_url: &str, target_dir: &Path, ref_: Option<&str>) -> Result<()> {
597    if target_dir.exists() {
598        bail!("Target directory already exists: {}", target_dir.display());
599    }
600    fs::create_dir_all(target_dir)
601        .with_context(|| format!("Failed to create {}", target_dir.display()))?;
602
603    let target_str = target_dir.to_string_lossy().to_string();
604    let args = vec!["clone", repo_url, &target_str];
605
606    git_command_silent(&args, None)?;
607
608    if let Some(r) = ref_ {
609        git_command_silent(&["checkout", r], Some(target_dir))?;
610    }
611
612    Ok(())
613}
614
615/// Pull/update a git repository in place
616pub fn git_update(repo_dir: &Path, ref_: Option<&str>) -> Result<bool> {
617    if !repo_dir.exists() {
618        bail!(
619            "Repository directory does not exist: {}",
620            repo_dir.display()
621        );
622    }
623
624    // Get current HEAD
625    let local_head = git_command(&["rev-parse", "HEAD"], Some(repo_dir))?;
626
627    // Determine what to fetch
628    let fetch_ref = if let Some(r) = ref_ {
629        r.to_string()
630    } else {
631        // Try to get upstream ref
632        match git_command(
633            &["rev-parse", "--abbrev-ref", "@{upstream}"],
634            Some(repo_dir),
635        ) {
636            Ok(upstream) => {
637                if let Some(branch) = upstream.strip_prefix("origin/") {
638                    format!("+refs/heads/{branch}:refs/remotes/origin/{branch}")
639                } else {
640                    "+HEAD:refs/remotes/origin/HEAD".to_string()
641                }
642            }
643            Err(_) => "+HEAD:refs/remotes/origin/HEAD".to_string(),
644        }
645    };
646
647    git_command_silent(
648        &["fetch", "--prune", "--no-tags", "origin", &fetch_ref],
649        Some(repo_dir),
650    )?;
651
652    // Determine what to reset to
653    let target_ref = ref_.unwrap_or("origin/HEAD");
654    let remote_head = git_command(&["rev-parse", target_ref], Some(repo_dir))?;
655
656    if local_head == remote_head {
657        return Ok(false); // No update needed
658    }
659
660    git_command_silent(&["reset", "--hard", target_ref], Some(repo_dir))?;
661    git_command_silent(&["clean", "-fdx"], Some(repo_dir))?;
662
663    Ok(true) // Updated
664}
665
666/// Check if a git repo has remote updates available
667pub fn git_has_update(repo_dir: &Path) -> Result<bool> {
668    let local_head = git_command(&["rev-parse", "HEAD"], Some(repo_dir))?;
669
670    // Try to get upstream
671    let upstream_ref = match git_command(
672        &["rev-parse", "--abbrev-ref", "@{upstream}"],
673        Some(repo_dir),
674    ) {
675        Ok(u) if u.starts_with("origin/") => {
676            let branch = &u["origin/".len()..];
677            format!("refs/heads/{branch}")
678        }
679        _ => "HEAD".to_string(),
680    };
681
682    // Fetch quietly and check remote
683    let _ = git_command_silent(&["fetch", "--prune", "--no-tags", "origin"], Some(repo_dir));
684
685    let remote_head = git_command(&["ls-remote", "origin", &upstream_ref], None)?;
686
687    // Parse first hash from ls-remote output
688    let remote_hash = remote_head
689        .lines()
690        .next()
691        .and_then(|line| line.split_whitespace().next())
692        .unwrap_or("");
693
694    Ok(local_head != remote_hash)
695}
696
697// ── Lockfile ──────────────────────────────────────────────────────────
698
699/// Lockfile entry for an installed package
700#[derive(Debug, Clone, Serialize, Deserialize)]
701pub struct LockEntry {
702    /// Source specifier
703    pub source: String,
704    /// Package name
705    pub name: String,
706    /// Resolved version or ref
707    pub version: String,
708    /// Integrity hash (sha256)
709    pub integrity: Option<String>,
710    /// Scope
711    pub scope: SourceScope,
712    /// Type of source
713    pub source_type: String,
714    /// Dependencies
715    #[serde(default)]
716    pub dependencies: BTreeMap<String, String>,
717}
718
719/// The lockfile structure
720#[derive(Debug, Clone, Serialize, Deserialize)]
721pub struct Lockfile {
722    /// Lockfile version
723    pub version: u32,
724    /// Locked packages
725    pub packages: BTreeMap<String, LockEntry>,
726}
727
728impl Lockfile {
729    /// Create a new empty lockfile
730    pub fn new() -> Self {
731        Self {
732            version: 1,
733            packages: BTreeMap::new(),
734        }
735    }
736
737    /// Read lockfile from disk
738    pub fn read(path: &Path) -> Result<Option<Self>> {
739        if !path.exists() {
740            return Ok(None);
741        }
742        let content = fs::read_to_string(path)
743            .with_context(|| format!("Failed to read lockfile {}", path.display()))?;
744        let lock: Lockfile = serde_json::from_str(&content)
745            .with_context(|| format!("Failed to parse lockfile {}", path.display()))?;
746        Ok(Some(lock))
747    }
748
749    /// Write lockfile to disk
750    pub fn write(&self, path: &Path) -> Result<()> {
751        let content = serde_json::to_string_pretty(self).context("Failed to serialize lockfile")?;
752        fs::write(path, content)
753            .with_context(|| format!("Failed to write lockfile {}", path.display()))?;
754        Ok(())
755    }
756
757    /// Add or update an entry
758    pub fn insert(&mut self, entry: LockEntry) {
759        self.packages.insert(entry.name.clone(), entry);
760    }
761
762    /// Remove an entry
763    pub fn remove(&mut self, name: &str) -> Option<LockEntry> {
764        self.packages.remove(name)
765    }
766
767    /// Check if a package is locked
768    pub fn contains(&self, name: &str) -> bool {
769        self.packages.contains_key(name)
770    }
771
772    /// Get an entry
773    pub fn get(&self, name: &str) -> Option<&LockEntry> {
774        self.packages.get(name)
775    }
776}
777
778impl Default for Lockfile {
779    fn default() -> Self {
780        Self::new()
781    }
782}
783
784// ── Counts ────────────────────────────────────────────────────────────
785
786/// Counts of each resource type in a package
787#[derive(Debug, Clone, Default, Serialize, Deserialize)]
788pub struct ResourceCounts {
789    /// pub.
790    pub extensions: usize,
791    /// pub.
792    pub skills: usize,
793    /// pub.
794    pub prompts: usize,
795    /// pub.
796    pub themes: usize,
797}
798
799impl std::fmt::Display for ResourceCounts {
800    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
801        let mut parts = Vec::new();
802        if self.extensions > 0 {
803            parts.push(format!("{} ext", self.extensions));
804        }
805        if self.skills > 0 {
806            parts.push(format!("{} skill", self.skills));
807        }
808        if self.prompts > 0 {
809            parts.push(format!("{} prompt", self.prompts));
810        }
811        if self.themes > 0 {
812            parts.push(format!("{} theme", self.themes));
813        }
814        if parts.is_empty() {
815            write!(f, "-")?;
816        } else {
817            write!(f, "{}", parts.join(", "))?;
818        }
819        Ok(())
820    }
821}
822
823// ── Package Manager ───────────────────────────────────────────────────
824
825/// Information about an available package update
826#[derive(Debug, Clone, Serialize, Deserialize)]
827pub struct PackageUpdateInfo {
828    /// pub.
829    pub source: String,
830    /// pub.
831    pub display_name: String,
832    /// pub.
833    pub source_type: String, // "npm" or "git"
834    /// pub.
835    pub scope: SourceScope,
836}
837
838/// A configured package
839#[derive(Debug, Clone, Serialize, Deserialize)]
840pub struct ConfiguredPackage {
841    /// pub.
842    pub source: String,
843    /// pub.
844    pub scope: SourceScope,
845    /// pub.
846    pub filtered: bool,
847    /// pub.
848    pub installed_path: Option<PathBuf>,
849}
850
851/// Manages installation, removal, and listing of packages
852pub struct PackageManager {
853    packages_dir: PathBuf,
854    /// Base directory for project-scoped packages
855    project_dir: PathBuf,
856    installed: HashMap<String, PackageManifest>,
857    lockfile: Lockfile,
858    progress_callback: Option<Box<dyn Fn(ProgressEvent) + Send + Sync>>,
859}
860
861impl PackageManager {
862    /// Create a new PackageManager using the default packages directory
863    pub fn new() -> Result<Self> {
864        let base = dirs::home_dir().context("Cannot determine home directory")?;
865        let packages_dir = base.join(".oxi").join("packages");
866        let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
867        let mut mgr = Self {
868            packages_dir,
869            project_dir,
870            installed: HashMap::new(),
871            lockfile: Lockfile::new(),
872            progress_callback: None,
873        };
874        mgr.load_installed()?;
875        mgr.load_lockfile()?;
876        Ok(mgr)
877    }
878
879    /// Create a PackageManager with a custom packages directory (for testing)
880    pub fn with_dir(packages_dir: PathBuf) -> Result<Self> {
881        let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
882        let mut mgr = Self {
883            packages_dir,
884            project_dir,
885            installed: HashMap::new(),
886            lockfile: Lockfile::new(),
887            progress_callback: None,
888        };
889        mgr.load_installed()?;
890        mgr.load_lockfile()?;
891        Ok(mgr)
892    }
893
894    /// Set the project directory for project-scoped packages
895    pub fn set_project_dir(&mut self, dir: PathBuf) {
896        self.project_dir = dir;
897    }
898
899    /// Set a progress callback
900    pub fn set_progress_callback(&mut self, callback: Box<dyn Fn(ProgressEvent) + Send + Sync>) {
901        self.progress_callback = Some(callback);
902    }
903
904    fn emit_progress(&self, event: ProgressEvent) {
905        if let Some(ref cb) = self.progress_callback {
906            cb(event);
907        }
908    }
909
910    // ── Loading ───────────────────────────────────────────────────────
911
912    /// Load all installed package manifests from disk
913    fn load_installed(&mut self) -> Result<()> {
914        if !self.packages_dir.exists() {
915            return Ok(());
916        }
917        for entry in fs::read_dir(&self.packages_dir)? {
918            let entry = entry?;
919            let manifest_path = entry.path().join(MANIFEST_NAME);
920            if manifest_path.exists() {
921                match Self::read_manifest(&manifest_path) {
922                    Ok(manifest) => {
923                        self.installed.insert(manifest.name.clone(), manifest);
924                    }
925                    Err(e) => {
926                        tracing::warn!(
927                            "Failed to load manifest {}: {}",
928                            manifest_path.display(),
929                            e
930                        );
931                    }
932                }
933            }
934        }
935        Ok(())
936    }
937
938    /// Load lockfile from disk
939    fn load_lockfile(&mut self) -> Result<()> {
940        let lock_path = self.packages_dir.join(LOCKFILE_NAME);
941        if let Some(lock) = Lockfile::read(&lock_path)? {
942            self.lockfile = lock;
943        }
944        Ok(())
945    }
946
947    /// Save lockfile to disk
948    fn save_lockfile(&self) -> Result<()> {
949        let lock_path = self.packages_dir.join(LOCKFILE_NAME);
950        self.lockfile.write(&lock_path)
951    }
952
953    // ── Manifest ──────────────────────────────────────────────────────
954
955    /// Read and parse a package manifest from disk
956    fn read_manifest(path: &Path) -> Result<PackageManifest> {
957        let content = fs::read_to_string(path)
958            .with_context(|| format!("Failed to read manifest {}", path.display()))?;
959        let manifest: PackageManifest = toml::from_str(&content)
960            .with_context(|| format!("Failed to parse manifest {}", path.display()))?;
961        Ok(manifest)
962    }
963
964    /// Try to read a `package.json` manifest (for npm packages)
965    fn read_package_json(dir: &Path) -> Option<serde_json::Value> {
966        let path = dir.join(NPM_MANIFEST_NAME);
967        let content = fs::read_to_string(path).ok()?;
968        serde_json::from_str(&content).ok()
969    }
970
971    // ── Path helpers ──────────────────────────────────────────────────
972
973    /// Get the installation directory for a package
974    fn pkg_install_dir(&self, name: &str) -> PathBuf {
975        let safe_name = name.replace('@', "").replace('/', "-");
976        self.packages_dir.join(safe_name)
977    }
978
979    /// Get the packages directory path
980    pub fn packages_dir(&self) -> &Path {
981        &self.packages_dir
982    }
983
984    /// Get install dir for a git source
985    fn git_install_path(&self, host: &str, path: &str, scope: SourceScope) -> PathBuf {
986        match scope {
987            SourceScope::Project => self
988                .project_dir
989                .join(".oxi")
990                .join("git")
991                .join(host)
992                .join(path),
993            SourceScope::User => self.packages_dir.join("git").join(host).join(path),
994        }
995    }
996
997    /// Get install dir for an npm source
998    fn npm_install_path(&self, name: &str, scope: SourceScope) -> PathBuf {
999        let safe_name = name.replace('@', "").replace('/', "-");
1000        match scope {
1001            SourceScope::Project => self.project_dir.join(".oxi").join("npm").join(safe_name),
1002            SourceScope::User => self.packages_dir.join("npm").join(safe_name),
1003        }
1004    }
1005
1006    // ── Install ───────────────────────────────────────────────────────
1007
1008    /// Ensure packages directory exists
1009    fn ensure_packages_dir(&self) -> Result<()> {
1010        fs::create_dir_all(&self.packages_dir).with_context(|| {
1011            format!(
1012                "Failed to create packages directory {}",
1013                self.packages_dir.display()
1014            )
1015        })
1016    }
1017
1018    /// Install a package from a local directory path
1019    pub fn install(&mut self, source: &str) -> Result<PackageManifest> {
1020        let parsed = ParsedSource::parse(source);
1021        match parsed {
1022            ParsedSource::Local { path } => self.install_local(&path),
1023            _ => bail!("Use install_from_source() for non-local packages"),
1024        }
1025    }
1026
1027    /// Install a package from a local directory path
1028    fn install_local(&mut self, path: &str) -> Result<PackageManifest> {
1029        let source_path = Path::new(path);
1030        let manifest_path = source_path.join(MANIFEST_NAME);
1031
1032        let manifest = if manifest_path.exists() {
1033            Self::read_manifest(&manifest_path)
1034                .with_context(|| format!("No valid {} found in {}", MANIFEST_NAME, path))?
1035        } else {
1036            // Synthesise a minimal manifest
1037            let name = source_path
1038                .file_name()
1039                .map(|n| n.to_string_lossy().to_string())
1040                .unwrap_or_else(|| "unknown".to_string());
1041            PackageManifest {
1042                name,
1043                version: "0.0.0".to_string(),
1044                extensions: Vec::new(),
1045                skills: Vec::new(),
1046                prompts: Vec::new(),
1047                themes: Vec::new(),
1048                description: None,
1049                dependencies: BTreeMap::new(),
1050            }
1051        };
1052
1053        let dest = self.pkg_install_dir(&manifest.name);
1054        self.ensure_packages_dir()?;
1055
1056        if dest.exists() {
1057            fs::remove_dir_all(&dest).with_context(|| {
1058                format!("Failed to remove existing package at {}", dest.display())
1059            })?;
1060        }
1061
1062        copy_dir_recursive(source_path, &dest).with_context(|| {
1063            format!("Failed to copy package from {} to {}", path, dest.display())
1064        })?;
1065
1066        let integrity = compute_dir_hash(&dest);
1067
1068        self.lockfile.insert(LockEntry {
1069            source: path.to_string(),
1070            name: manifest.name.clone(),
1071            version: manifest.version.clone(),
1072            integrity,
1073            scope: SourceScope::User,
1074            source_type: "local".to_string(),
1075            dependencies: manifest.dependencies.clone(),
1076        });
1077
1078        self.installed
1079            .insert(manifest.name.clone(), manifest.clone());
1080        let _ = self.save_lockfile();
1081        Ok(manifest)
1082    }
1083
1084    /// Install from any source
1085    pub fn install_from_source(
1086        &mut self,
1087        source: &str,
1088        scope: SourceScope,
1089    ) -> Result<PackageManifest> {
1090        let parsed = ParsedSource::parse(source);
1091        self.emit_progress(ProgressEvent {
1092            event_type: ProgressEventType::Start,
1093            action: ProgressAction::Install,
1094            source: source.to_string(),
1095            message: Some(format!("Installing {}...", source)),
1096        });
1097        let result = match &parsed {
1098            ParsedSource::Npm { .. } => {
1099                let rt = tokio::runtime::Runtime::new()?;
1100                rt.block_on(self.install_npm_async(source, scope))
1101            }
1102            ParsedSource::Git { repo, ref_, .. } => {
1103                self.install_git_sync(source, repo, ref_.as_deref(), scope)
1104            }
1105            ParsedSource::Local { path } => self.install_local(path),
1106            ParsedSource::Url { url } => {
1107                let rt = tokio::runtime::Runtime::new()?;
1108                rt.block_on(self.install_url(url, scope))
1109            }
1110        };
1111        match &result {
1112            Ok(_) => self.emit_progress(ProgressEvent {
1113                event_type: ProgressEventType::Complete,
1114                action: ProgressAction::Install,
1115                source: source.to_string(),
1116                message: None,
1117            }),
1118            Err(e) => self.emit_progress(ProgressEvent {
1119                event_type: ProgressEventType::Error,
1120                action: ProgressAction::Install,
1121                source: source.to_string(),
1122                message: Some(e.to_string()),
1123            }),
1124        }
1125        result
1126    }
1127
1128    /// Async install from npm using registry
1129    async fn install_npm_async(
1130        &mut self,
1131        source: &str,
1132        scope: SourceScope,
1133    ) -> Result<PackageManifest> {
1134        let parsed = ParsedSource::parse(source);
1135        let (spec, name, pinned) = match &parsed {
1136            ParsedSource::Npm { spec, name, pinned } => (spec.clone(), name.clone(), *pinned),
1137            _ => bail!("Expected npm source"),
1138        };
1139
1140        // Resolve version
1141        let _version = if pinned {
1142            // Extract version from spec
1143            let (_, ver) = parse_npm_spec(&spec);
1144            if ver {
1145                spec.rsplit('@').next().unwrap_or("latest").to_string()
1146            } else {
1147                "latest".to_string()
1148            }
1149        } else {
1150            get_latest_npm_version(&name)
1151                .await
1152                .unwrap_or_else(|_| "latest".to_string())
1153        };
1154
1155        // Use npm pack approach
1156        self.install_npm_pack(&spec, scope)
1157    }
1158
1159    /// Install npm package using `npm pack`
1160    fn install_npm_pack(&mut self, spec: &str, scope: SourceScope) -> Result<PackageManifest> {
1161        let tmp_dir =
1162            tempfile::tempdir().context("Failed to create temp directory for npm install")?;
1163
1164        let output = std::process::Command::new("npm")
1165            .args(["pack", spec, "--pack-destination"])
1166            .arg(tmp_dir.path())
1167            .current_dir(tmp_dir.path())
1168            .output()
1169            .context("Failed to run npm pack")?;
1170
1171        if !output.status.success() {
1172            let stderr = String::from_utf8_lossy(&output.stderr);
1173            bail!("npm pack failed for '{}': {}", spec, stderr);
1174        }
1175
1176        // Find the tarball
1177        let tarball = fs::read_dir(tmp_dir.path())?
1178            .filter_map(|e| e.ok())
1179            .find(|e| {
1180                e.path()
1181                    .extension()
1182                    .map(|ext| ext == "tgz")
1183                    .unwrap_or(false)
1184            })
1185            .map(|e| e.path())
1186            .context("No .tgz file found after npm pack")?;
1187
1188        // Extract tarball
1189        let extract_dir = tmp_dir.path().join("extracted");
1190        fs::create_dir_all(&extract_dir)?;
1191
1192        let tar_status = std::process::Command::new("tar")
1193            .args(["-xzf", &tarball.to_string_lossy(), "-C"])
1194            .arg(&extract_dir)
1195            .output()
1196            .context("Failed to run tar")?;
1197
1198        if !tar_status.status.success() {
1199            let stderr = String::from_utf8_lossy(&tar_status.stderr);
1200            bail!("tar extraction failed: {}", stderr);
1201        }
1202
1203        // npm pack extracts into a "package" subdirectory
1204        let pkg_source = extract_dir.join("package");
1205        let source_for_copy = if pkg_source.exists() {
1206            &pkg_source
1207        } else {
1208            // Might be just the extracted dir
1209            extract_dir.as_path()
1210        };
1211
1212        self.ensure_packages_dir()?;
1213
1214        // Determine package name from manifest or spec
1215        let manifest = if source_for_copy.join(MANIFEST_NAME).exists() {
1216            Self::read_manifest(&source_for_copy.join(MANIFEST_NAME))?
1217        } else if source_for_copy.join(NPM_MANIFEST_NAME).exists() {
1218            let pj = Self::read_package_json(source_for_copy);
1219            let (pkg_name, pkg_version) = pj
1220                .as_ref()
1221                .map(|v| {
1222                    (
1223                        v.get("name")
1224                            .and_then(|n| n.as_str())
1225                            .unwrap_or(spec)
1226                            .to_string(),
1227                        v.get("version")
1228                            .and_then(|v| v.as_str())
1229                            .unwrap_or("0.0.0")
1230                            .to_string(),
1231                    )
1232                })
1233                .unwrap_or((spec.to_string(), "0.0.0".to_string()));
1234
1235            PackageManifest {
1236                name: pkg_name,
1237                version: pkg_version,
1238                extensions: Vec::new(),
1239                skills: Vec::new(),
1240                prompts: Vec::new(),
1241                themes: Vec::new(),
1242                description: None,
1243                dependencies: BTreeMap::new(),
1244            }
1245        } else {
1246            PackageManifest {
1247                name: spec.to_string(),
1248                version: "0.0.0".to_string(),
1249                extensions: Vec::new(),
1250                skills: Vec::new(),
1251                prompts: Vec::new(),
1252                themes: Vec::new(),
1253                description: None,
1254                dependencies: BTreeMap::new(),
1255            }
1256        };
1257
1258        let dest = self.pkg_install_dir(&manifest.name);
1259        if dest.exists() {
1260            fs::remove_dir_all(&dest).with_context(|| {
1261                format!("Failed to remove existing package at {}", dest.display())
1262            })?;
1263        }
1264
1265        copy_dir_recursive(source_for_copy, &dest)
1266            .with_context(|| format!("Failed to copy npm package for '{}'", spec))?;
1267
1268        let integrity = compute_dir_hash(&dest);
1269
1270        self.lockfile.insert(LockEntry {
1271            source: format!("npm:{}", spec),
1272            name: manifest.name.clone(),
1273            version: manifest.version.clone(),
1274            integrity,
1275            scope,
1276            source_type: "npm".to_string(),
1277            dependencies: manifest.dependencies.clone(),
1278        });
1279
1280        self.installed
1281            .insert(manifest.name.clone(), manifest.clone());
1282        let _ = self.save_lockfile();
1283        Ok(manifest)
1284    }
1285
1286    /// Install from git
1287    fn install_git_sync(
1288        &mut self,
1289        source: &str,
1290        repo: &str,
1291        ref_: Option<&str>,
1292        scope: SourceScope,
1293    ) -> Result<PackageManifest> {
1294        let parsed = ParsedSource::parse(source);
1295        let (host, path) = match &parsed {
1296            ParsedSource::Git { host, path, .. } => (host.clone(), path.clone()),
1297            _ => bail!("Expected git source"),
1298        };
1299
1300        let target_dir = self.git_install_path(&host, &path, scope);
1301
1302        if target_dir.exists() {
1303            // Already installed
1304            return self.load_manifest_from_dir(&target_dir, source, scope);
1305        }
1306
1307        let Some(parent) = target_dir.parent() else {
1308            bail!(
1309                "Invalid install path: no parent directory for {}",
1310                target_dir.display()
1311            );
1312        };
1313        fs::create_dir_all(parent)
1314            .with_context(|| format!("Failed to create parent dir for {}", target_dir.display()))?;
1315
1316        git_clone(repo, &target_dir, ref_)?;
1317
1318        // Install npm dependencies if package.json exists
1319        if target_dir.join(NPM_MANIFEST_NAME).exists() {
1320            let _ = std::process::Command::new("npm")
1321                .args(["install", "--omit=dev"])
1322                .current_dir(&target_dir)
1323                .output();
1324        }
1325
1326        self.load_manifest_from_dir(&target_dir, source, scope)
1327    }
1328
1329    /// Load manifest from a directory and register it
1330    fn load_manifest_from_dir(
1331        &mut self,
1332        dir: &Path,
1333        source: &str,
1334        scope: SourceScope,
1335    ) -> Result<PackageManifest> {
1336        let manifest = if dir.join(MANIFEST_NAME).exists() {
1337            Self::read_manifest(&dir.join(MANIFEST_NAME))?
1338        } else {
1339            let name = dir
1340                .file_name()
1341                .map(|n| n.to_string_lossy().to_string())
1342                .unwrap_or_else(|| "unknown".to_string());
1343            PackageManifest {
1344                name,
1345                version: "0.0.0".to_string(),
1346                extensions: Vec::new(),
1347                skills: Vec::new(),
1348                prompts: Vec::new(),
1349                themes: Vec::new(),
1350                description: None,
1351                dependencies: BTreeMap::new(),
1352            }
1353        };
1354
1355        let integrity = compute_dir_hash(dir);
1356
1357        self.lockfile.insert(LockEntry {
1358            source: source.to_string(),
1359            name: manifest.name.clone(),
1360            version: manifest.version.clone(),
1361            integrity,
1362            scope,
1363            source_type: "git".to_string(),
1364            dependencies: manifest.dependencies.clone(),
1365        });
1366
1367        self.installed
1368            .insert(manifest.name.clone(), manifest.clone());
1369        let _ = self.save_lockfile();
1370        Ok(manifest)
1371    }
1372
1373    /// Install from a URL (archive)
1374    async fn install_url(&mut self, url: &str, scope: SourceScope) -> Result<PackageManifest> {
1375        let client = shared_http_client();
1376
1377        let resp = client.get(url).send().await?;
1378        if !resp.status().is_success() {
1379            bail!("Failed to download {}: {}", url, resp.status());
1380        }
1381
1382        let bytes = resp.bytes().await?;
1383
1384        let tmp_dir = tempfile::tempdir()?;
1385        let archive_name = url.split('/').next_back().unwrap_or("archive");
1386        let archive_path = tmp_dir.path().join(archive_name);
1387        fs::write(&archive_path, &bytes)?;
1388
1389        let extract_dir = tmp_dir.path().join("extracted");
1390        fs::create_dir_all(&extract_dir)?;
1391
1392        if archive_name.ends_with(".tar.gz") || archive_name.ends_with(".tgz") {
1393            let status = std::process::Command::new("tar")
1394                .args(["-xzf", &archive_path.to_string_lossy(), "-C"])
1395                .arg(&extract_dir)
1396                .output()?;
1397            if !status.status.success() {
1398                bail!("Failed to extract archive");
1399            }
1400        } else if archive_name.ends_with(".zip") {
1401            // Use unzip if available
1402            let status = std::process::Command::new("unzip")
1403                .arg("-o")
1404                .arg(&archive_path)
1405                .arg("-d")
1406                .arg(&extract_dir)
1407                .output()?;
1408            if !status.status.success() {
1409                bail!("Failed to extract zip archive");
1410            }
1411        } else {
1412            bail!("Unsupported archive format: {}", archive_name);
1413        }
1414
1415        // Find the extracted package directory
1416        let pkg_dir = find_single_subdir(&extract_dir).unwrap_or_else(|| extract_dir.to_path_buf());
1417
1418        self.ensure_packages_dir()?;
1419
1420        let manifest = if pkg_dir.join(MANIFEST_NAME).exists() {
1421            Self::read_manifest(&pkg_dir.join(MANIFEST_NAME))?
1422        } else {
1423            let name = url
1424                .split('/')
1425                .next_back()
1426                .unwrap_or("url-package")
1427                .trim_end_matches(".tar.gz")
1428                .trim_end_matches(".tgz")
1429                .trim_end_matches(".zip")
1430                .to_string();
1431            PackageManifest {
1432                name,
1433                version: "0.0.0".to_string(),
1434                extensions: Vec::new(),
1435                skills: Vec::new(),
1436                prompts: Vec::new(),
1437                themes: Vec::new(),
1438                description: None,
1439                dependencies: BTreeMap::new(),
1440            }
1441        };
1442
1443        let dest = self.pkg_install_dir(&manifest.name);
1444        if dest.exists() {
1445            fs::remove_dir_all(&dest)?;
1446        }
1447
1448        copy_dir_recursive(&pkg_dir, &dest)?;
1449
1450        let integrity = compute_dir_hash(&dest);
1451
1452        self.lockfile.insert(LockEntry {
1453            source: url.to_string(),
1454            name: manifest.name.clone(),
1455            version: manifest.version.clone(),
1456            integrity,
1457            scope,
1458            source_type: "url".to_string(),
1459            dependencies: manifest.dependencies.clone(),
1460        });
1461
1462        self.installed
1463            .insert(manifest.name.clone(), manifest.clone());
1464        let _ = self.save_lockfile();
1465        Ok(manifest)
1466    }
1467
1468    /// Install from npm using `npm pack` (legacy sync method)
1469    pub fn install_npm(&mut self, name: &str) -> Result<PackageManifest> {
1470        self.install_npm_pack(name, SourceScope::User)
1471    }
1472
1473    // ── Uninstall ─────────────────────────────────────────────────────
1474
1475    /// Uninstall a package by name
1476    pub fn uninstall(&mut self, name: &str) -> Result<()> {
1477        if !self.installed.contains_key(name) {
1478            bail!("Package '{}' is not installed", name);
1479        }
1480
1481        let dest = self.pkg_install_dir(name);
1482        if dest.exists() {
1483            fs::remove_dir_all(&dest).with_context(|| {
1484                format!("Failed to remove package directory {}", dest.display())
1485            })?;
1486        }
1487
1488        // Also try to clean up git/npm scoped dirs
1489        // (best effort)
1490        let _ = self.lockfile.remove(name);
1491        let _ = self.save_lockfile();
1492
1493        self.installed.remove(name);
1494        Ok(())
1495    }
1496
1497    /// Uninstall a package from a specific source
1498    pub fn uninstall_from_source(&mut self, source: &str, scope: SourceScope) -> Result<()> {
1499        let parsed = ParsedSource::parse(source);
1500        self.emit_progress(ProgressEvent {
1501            event_type: ProgressEventType::Start,
1502            action: ProgressAction::Remove,
1503            source: source.to_string(),
1504            message: Some(format!("Removing {}...", source)),
1505        });
1506        let result = self.do_uninstall_from_source(&parsed, scope);
1507        match &result {
1508            Ok(_) => self.emit_progress(ProgressEvent {
1509                event_type: ProgressEventType::Complete,
1510                action: ProgressAction::Remove,
1511                source: source.to_string(),
1512                message: None,
1513            }),
1514            Err(e) => self.emit_progress(ProgressEvent {
1515                event_type: ProgressEventType::Error,
1516                action: ProgressAction::Remove,
1517                source: source.to_string(),
1518                message: Some(e.to_string()),
1519            }),
1520        }
1521        result
1522    }
1523
1524    fn do_uninstall_from_source(
1525        &mut self,
1526        parsed: &ParsedSource,
1527        scope: SourceScope,
1528    ) -> Result<()> {
1529        match parsed {
1530            ParsedSource::Npm { name, .. } => {
1531                let dest = self.npm_install_path(name, scope);
1532                if dest.exists() {
1533                    fs::remove_dir_all(&dest)?;
1534                }
1535                self.installed.remove(name);
1536                self.lockfile.remove(name);
1537                let _ = self.save_lockfile();
1538                Ok(())
1539            }
1540            ParsedSource::Git { host, path, .. } => {
1541                let dest = self.git_install_path(host, path, scope);
1542                if dest.exists() {
1543                    fs::remove_dir_all(&dest)?;
1544                    prune_empty_parents(&dest, &self.packages_dir);
1545                }
1546                self.installed.retain(|_, m| {
1547                    let parsed_m = ParsedSource::parse(m.name.as_str());
1548                    parsed_m.identity() != parsed.identity()
1549                });
1550                self.lockfile.packages.retain(|_, entry| {
1551                    let parsed_e = ParsedSource::parse(&entry.source);
1552                    parsed_e.identity() != parsed.identity()
1553                });
1554                let _ = self.save_lockfile();
1555                Ok(())
1556            }
1557            ParsedSource::Local { .. } => Ok(()),
1558            ParsedSource::Url { .. } => {
1559                let identity = parsed.identity();
1560                self.lockfile
1561                    .packages
1562                    .retain(|_, e| ParsedSource::parse(&e.source).identity() != identity);
1563                let _ = self.save_lockfile();
1564                Ok(())
1565            }
1566        }
1567    }
1568
1569    // ── Update ────────────────────────────────────────────────────────
1570
1571    /// Update a package (re-install from the same source).
1572    /// For npm packages, re-runs `npm pack` to get the latest version.
1573    /// For local packages, re-copies from the source path (if available).
1574    /// For git packages, does a git pull.
1575    pub fn update(&mut self, name: &str) -> Result<PackageManifest> {
1576        let lock_entry = self.lockfile.get(name).cloned();
1577
1578        if let Some(entry) = lock_entry {
1579            let parsed = ParsedSource::parse(&entry.source);
1580            return match &parsed {
1581                ParsedSource::Npm { spec, .. } => {
1582                    self.emit_progress(ProgressEvent {
1583                        event_type: ProgressEventType::Start,
1584                        action: ProgressAction::Update,
1585                        source: entry.source.clone(),
1586                        message: Some(format!("Updating {}...", name)),
1587                    });
1588                    let result = self.install_npm_pack(spec, entry.scope);
1589                    match &result {
1590                        Ok(_) => self.emit_progress(ProgressEvent {
1591                            event_type: ProgressEventType::Complete,
1592                            action: ProgressAction::Update,
1593                            source: entry.source.clone(),
1594                            message: None,
1595                        }),
1596                        Err(e) => self.emit_progress(ProgressEvent {
1597                            event_type: ProgressEventType::Error,
1598                            action: ProgressAction::Update,
1599                            source: entry.source.clone(),
1600                            message: Some(e.to_string()),
1601                        }),
1602                    }
1603                    result
1604                }
1605                ParsedSource::Git { repo, ref_, .. } => {
1606                    let target_dir = match &parsed {
1607                        ParsedSource::Git { host, path, .. } => {
1608                            self.git_install_path(host, path, entry.scope)
1609                        }
1610                        _ => unreachable!(),
1611                    };
1612                    if target_dir.exists() {
1613                        let updated = git_update(&target_dir, ref_.as_deref())?;
1614                        if updated && target_dir.join(NPM_MANIFEST_NAME).exists() {
1615                            let _ = std::process::Command::new("npm")
1616                                .args(["install", "--omit=dev"])
1617                                .current_dir(&target_dir)
1618                                .output();
1619                        }
1620                        self.load_manifest_from_dir(&target_dir, &entry.source, entry.scope)
1621                    } else {
1622                        self.install_git_sync(&entry.source, repo, ref_.as_deref(), entry.scope)
1623                    }
1624                }
1625                ParsedSource::Local { path } => self.install_local(path),
1626                ParsedSource::Url { url } => {
1627                    let rt = tokio::runtime::Runtime::new()?;
1628                    rt.block_on(self.install_url(url, entry.scope))
1629                }
1630            };
1631        }
1632
1633        // Fallback: try npm re-install
1634        if self.installed.contains_key(name) {
1635            self.install_npm_pack(name, SourceScope::User)
1636        } else {
1637            bail!("Package '{}' is not installed", name);
1638        }
1639    }
1640
1641    /// Update all installed packages
1642    pub fn update_all(&mut self) -> Vec<(String, Result<PackageManifest>)> {
1643        let names: Vec<String> = self.installed.keys().cloned().collect();
1644        let mut results = Vec::new();
1645        for name in names {
1646            let result = self.update(&name);
1647            results.push((name, result));
1648        }
1649        results
1650    }
1651
1652    /// Check for available updates across all packages
1653    pub async fn check_for_updates(&self) -> Vec<PackageUpdateInfo> {
1654        let mut updates = Vec::new();
1655
1656        for lock_entry in self.lockfile.packages.values() {
1657            let parsed = ParsedSource::parse(&lock_entry.source);
1658
1659            match &parsed {
1660                ParsedSource::Npm { name: pkg_name, .. } => {
1661                    // Check npm for newer version
1662                    match NpmPackageInfo::fetch(pkg_name).await {
1663                        Ok(info) => {
1664                            if let Some(latest) = info.latest_version() {
1665                                if latest != lock_entry.version {
1666                                    updates.push(PackageUpdateInfo {
1667                                        source: lock_entry.source.clone(),
1668                                        display_name: pkg_name.clone(),
1669                                        source_type: "npm".to_string(),
1670                                        scope: lock_entry.scope,
1671                                    });
1672                                }
1673                            }
1674                        }
1675                        Err(_) => continue,
1676                    }
1677                }
1678                ParsedSource::Git { host, path, .. } => {
1679                    let install_path = self.git_install_path(host, path, lock_entry.scope);
1680                    if install_path.exists() {
1681                        match git_has_update(&install_path) {
1682                            Ok(true) => {
1683                                updates.push(PackageUpdateInfo {
1684                                    source: lock_entry.source.clone(),
1685                                    display_name: format!("{}/{}", host, path),
1686                                    source_type: "git".to_string(),
1687                                    scope: lock_entry.scope,
1688                                });
1689                            }
1690                            _ => continue,
1691                        }
1692                    }
1693                }
1694                _ => continue,
1695            }
1696        }
1697
1698        updates
1699    }
1700
1701    // ── List / query ──────────────────────────────────────────────────
1702
1703    /// List all installed packages
1704    pub fn list(&self) -> Vec<&PackageManifest> {
1705        self.installed.values().collect()
1706    }
1707
1708    /// List configured packages with metadata
1709    pub fn list_configured(&self) -> Vec<ConfiguredPackage> {
1710        let mut result = Vec::new();
1711        for name in self.installed.keys() {
1712            let installed_path = self.get_install_dir(name);
1713            let lock_entry = self.lockfile.get(name);
1714            result.push(ConfiguredPackage {
1715                source: lock_entry
1716                    .map(|e| e.source.clone())
1717                    .unwrap_or_else(|| name.clone()),
1718                scope: lock_entry.map(|e| e.scope).unwrap_or(SourceScope::User),
1719                filtered: false,
1720                installed_path,
1721            });
1722        }
1723        result
1724    }
1725
1726    /// Check whether a package is installed
1727    pub fn is_installed(&self, name: &str) -> bool {
1728        self.installed.contains_key(name)
1729    }
1730
1731    /// Get the install directory for a package (if it exists on disk)
1732    pub fn get_install_dir(&self, name: &str) -> Option<PathBuf> {
1733        let dir = self.pkg_install_dir(name);
1734        if dir.exists() {
1735            Some(dir)
1736        } else {
1737            None
1738        }
1739    }
1740
1741    /// Get the installed path for a source at a given scope
1742    pub fn get_installed_path_for_source(
1743        &self,
1744        source: &str,
1745        scope: SourceScope,
1746    ) -> Option<PathBuf> {
1747        let parsed = ParsedSource::parse(source);
1748        match &parsed {
1749            ParsedSource::Npm { name, .. } => {
1750                let path = self.npm_install_path(name, scope);
1751                if path.exists() {
1752                    Some(path)
1753                } else {
1754                    None
1755                }
1756            }
1757            ParsedSource::Git { host, path, .. } => {
1758                let path = self.git_install_path(host, path, scope);
1759                if path.exists() {
1760                    Some(path)
1761                } else {
1762                    None
1763                }
1764            }
1765            ParsedSource::Local { path } => {
1766                let p = PathBuf::from(path);
1767                if p.exists() {
1768                    Some(p)
1769                } else {
1770                    None
1771                }
1772            }
1773            ParsedSource::Url { .. } => None,
1774        }
1775    }
1776
1777    // ── Resource discovery ────────────────────────────────────────────
1778
1779    /// Discover all resources from an installed package.
1780    pub fn discover_resources(&self, name: &str) -> Result<Vec<DiscoveredResource>> {
1781        let manifest = self
1782            .installed
1783            .get(name)
1784            .with_context(|| format!("Package '{}' not found", name))?;
1785
1786        let install_dir = self.pkg_install_dir(name);
1787        if !install_dir.exists() {
1788            bail!("Install directory for '{}' does not exist", name);
1789        }
1790
1791        let mut resources = Vec::new();
1792
1793        let has_explicit = !manifest.extensions.is_empty()
1794            || !manifest.skills.is_empty()
1795            || !manifest.prompts.is_empty()
1796            || !manifest.themes.is_empty();
1797
1798        if has_explicit {
1799            for ext in &manifest.extensions {
1800                let path = install_dir.join(ext);
1801                if path.exists() {
1802                    resources.push(DiscoveredResource {
1803                        kind: ResourceKind::Extension,
1804                        path,
1805                        relative_path: ext.clone(),
1806                    });
1807                }
1808            }
1809            for skill in &manifest.skills {
1810                let path = install_dir.join(skill);
1811                if path.exists() {
1812                    resources.push(DiscoveredResource {
1813                        kind: ResourceKind::Skill,
1814                        path,
1815                        relative_path: skill.clone(),
1816                    });
1817                }
1818            }
1819            for prompt in &manifest.prompts {
1820                let path = install_dir.join(prompt);
1821                if path.exists() {
1822                    resources.push(DiscoveredResource {
1823                        kind: ResourceKind::Prompt,
1824                        path,
1825                        relative_path: prompt.clone(),
1826                    });
1827                }
1828            }
1829            for theme in &manifest.themes {
1830                let path = install_dir.join(theme);
1831                if path.exists() {
1832                    resources.push(DiscoveredResource {
1833                        kind: ResourceKind::Theme,
1834                        path,
1835                        relative_path: theme.clone(),
1836                    });
1837                }
1838            }
1839        } else {
1840            resources.extend(discover_extensions(&install_dir));
1841            resources.extend(discover_skills(&install_dir));
1842            resources.extend(discover_prompts(&install_dir));
1843            resources.extend(discover_themes(&install_dir));
1844        }
1845
1846        Ok(resources)
1847    }
1848
1849    /// Get resource counts for a package
1850    pub fn resource_counts(&self, name: &str) -> Result<ResourceCounts> {
1851        let resources = self.discover_resources(name)?;
1852        let mut counts = ResourceCounts::default();
1853        for r in &resources {
1854            match r.kind {
1855                ResourceKind::Extension => counts.extensions += 1,
1856                ResourceKind::Skill => counts.skills += 1,
1857                ResourceKind::Prompt => counts.prompts += 1,
1858                ResourceKind::Theme => counts.themes += 1,
1859            }
1860        }
1861        Ok(counts)
1862    }
1863
1864    /// Resolve all resources from all installed packages, producing ResolvedPaths
1865    pub fn resolve(&self) -> ResolvedPaths {
1866        let mut extensions = Vec::new();
1867        let mut skills = Vec::new();
1868        let mut prompts = Vec::new();
1869        let mut themes = Vec::new();
1870
1871        for name in self.installed.keys() {
1872            let install_dir = self.pkg_install_dir(name);
1873            if !install_dir.exists() {
1874                continue;
1875            }
1876
1877            let metadata = PathMetadata {
1878                source: name.clone(),
1879                scope: SourceScope::User,
1880                origin: ResourceOrigin::Package,
1881                base_dir: Some(install_dir.clone()),
1882            };
1883
1884            // Use discover_resources logic
1885            if let Ok(resources) = self.discover_resources(name) {
1886                for r in resources {
1887                    match r.kind {
1888                        ResourceKind::Extension => extensions.push(ResolvedResource {
1889                            path: r.path,
1890                            enabled: true,
1891                            metadata: metadata.clone(),
1892                        }),
1893                        ResourceKind::Skill => skills.push(ResolvedResource {
1894                            path: r.path,
1895                            enabled: true,
1896                            metadata: metadata.clone(),
1897                        }),
1898                        ResourceKind::Prompt => prompts.push(ResolvedResource {
1899                            path: r.path,
1900                            enabled: true,
1901                            metadata: metadata.clone(),
1902                        }),
1903                        ResourceKind::Theme => themes.push(ResolvedResource {
1904                            path: r.path,
1905                            enabled: true,
1906                            metadata: metadata.clone(),
1907                        }),
1908                    }
1909                }
1910            }
1911        }
1912
1913        ResolvedPaths {
1914            extensions,
1915            skills,
1916            prompts,
1917            themes,
1918        }
1919    }
1920
1921    // ── Dependency resolution ─────────────────────────────────────────
1922
1923    /// Resolve dependencies for all installed packages.
1924    /// Returns a list of (package, missing_dependencies) tuples.
1925    pub fn resolve_dependencies(&self) -> Vec<(String, Vec<String>)> {
1926        let mut result = Vec::new();
1927        let installed_names: HashSet<&str> = self.installed.keys().map(|s| s.as_str()).collect();
1928
1929        for (name, manifest) in &self.installed {
1930            let missing: Vec<String> = manifest
1931                .dependencies
1932                .keys()
1933                .filter(|dep| !installed_names.contains(dep.as_str()))
1934                .cloned()
1935                .collect();
1936
1937            if !missing.is_empty() {
1938                result.push((name.clone(), missing));
1939            }
1940        }
1941
1942        result
1943    }
1944
1945    /// Validate a package structure
1946    pub fn validate_package(dir: &Path) -> Result<Vec<String>> {
1947        let mut warnings = Vec::new();
1948
1949        // Check for manifest
1950        if !dir.join(MANIFEST_NAME).exists() && !dir.join(NPM_MANIFEST_NAME).exists() {
1951            warnings.push(format!(
1952                "No {} or {} found",
1953                MANIFEST_NAME, NPM_MANIFEST_NAME
1954            ));
1955        }
1956
1957        // Try to parse manifest
1958        if dir.join(MANIFEST_NAME).exists() {
1959            match Self::read_manifest(&dir.join(MANIFEST_NAME)) {
1960                Ok(m) => {
1961                    if m.name.is_empty() {
1962                        warnings.push("Package name is empty".to_string());
1963                    }
1964                    if m.version.is_empty() {
1965                        warnings.push("Package version is empty".to_string());
1966                    }
1967                    if semver::Version::parse(&m.version).is_err() {
1968                        warnings.push(format!("Version '{}' is not valid semver", m.version));
1969                    }
1970                    let has_resources = !m.extensions.is_empty()
1971                        || !m.skills.is_empty()
1972                        || !m.prompts.is_empty()
1973                        || !m.themes.is_empty();
1974                    if !has_resources {
1975                        // Check if auto-discovery would find anything
1976                        let discovered = discover_extensions(dir)
1977                            .into_iter()
1978                            .chain(discover_skills(dir))
1979                            .chain(discover_prompts(dir))
1980                            .chain(discover_themes(dir))
1981                            .count();
1982                        if discovered == 0 {
1983                            warnings.push(
1984                                "Package has no explicit resources and auto-discovery found nothing"
1985                                    .to_string(),
1986                            );
1987                        }
1988                    }
1989
1990                    // Check that explicit paths exist
1991                    for ext in &m.extensions {
1992                        if !dir.join(ext).exists() {
1993                            warnings.push(format!("Extension path '{}' does not exist", ext));
1994                        }
1995                    }
1996                    for skill in &m.skills {
1997                        if !dir.join(skill).exists() {
1998                            warnings.push(format!("Skill path '{}' does not exist", skill));
1999                        }
2000                    }
2001                    for prompt in &m.prompts {
2002                        if !dir.join(prompt).exists() {
2003                            warnings.push(format!("Prompt path '{}' does not exist", prompt));
2004                        }
2005                    }
2006                    for theme in &m.themes {
2007                        if !dir.join(theme).exists() {
2008                            warnings.push(format!("Theme path '{}' does not exist", theme));
2009                        }
2010                    }
2011                }
2012                Err(e) => {
2013                    warnings.push(format!("Failed to parse {}: {}", MANIFEST_NAME, e));
2014                }
2015            }
2016        }
2017
2018        // Check for .gitignore or .ignore
2019        if !dir.join(".gitignore").exists() && !dir.join(".ignore").exists() {
2020            warnings.push("No .gitignore or .ignore file found".to_string());
2021        }
2022
2023        Ok(warnings)
2024    }
2025
2026    // ── Version queries ───────────────────────────────────────────────
2027
2028    /// Get installed version of a package
2029    pub fn get_installed_version(&self, name: &str) -> Option<&str> {
2030        self.installed.get(name).map(|m| m.version.as_str())
2031    }
2032
2033    /// Check if an installed version satisfies a semver requirement
2034    pub fn version_satisfies(&self, name: &str, requirement: &str) -> bool {
2035        if let Some(version) = self.get_installed_version(name) {
2036            if let Ok(v) = semver::Version::parse(version) {
2037                if let Ok(req) = semver::VersionReq::parse(requirement) {
2038                    return req.matches(&v);
2039                }
2040            }
2041        }
2042        false
2043    }
2044
2045    /// Get the lockfile
2046    pub fn lockfile(&self) -> &Lockfile {
2047        &self.lockfile
2048    }
2049}
2050
2051// ── Auto-discovery helpers ────────────────────────────────────────────
2052
2053/// Discover extension files in a directory.
2054fn discover_extensions(dir: &Path) -> Vec<DiscoveredResource> {
2055    let mut results = Vec::new();
2056    discover_extensions_recursive(dir, dir, &mut results);
2057    results
2058}
2059
2060fn discover_extensions_recursive(
2061    base: &Path,
2062    current: &Path,
2063    results: &mut Vec<DiscoveredResource>,
2064) {
2065    if !current.exists() {
2066        return;
2067    }
2068
2069    let entries = match fs::read_dir(current) {
2070        Ok(e) => e,
2071        Err(_) => return,
2072    };
2073
2074    for entry in entries.flatten() {
2075        let path = entry.path();
2076        let name = entry.file_name();
2077        let name_str = name.to_string_lossy();
2078
2079        if name_str.starts_with('.') || name_str == "node_modules" {
2080            continue;
2081        }
2082
2083        if path.is_dir() {
2084            // Check for index.ts / index.js in subdirectory
2085            for index in &["index.ts", "index.js"] {
2086                let index_path = path.join(index);
2087                if index_path.exists() {
2088                    let rel = path.strip_prefix(base).unwrap_or(&path);
2089                    results.push(DiscoveredResource {
2090                        kind: ResourceKind::Extension,
2091                        path: index_path,
2092                        relative_path: rel.join(index).to_string_lossy().to_string(),
2093                    });
2094                }
2095            }
2096        } else {
2097            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
2098            if matches!(ext, "so" | "dylib" | "dll" | "ts" | "js") {
2099                let rel = path.strip_prefix(base).unwrap_or(&path);
2100                results.push(DiscoveredResource {
2101                    kind: ResourceKind::Extension,
2102                    path: path.clone(),
2103                    relative_path: rel.to_string_lossy().to_string(),
2104                });
2105            }
2106        }
2107    }
2108}
2109
2110/// Discover skill directories containing SKILL.md
2111fn discover_skills(dir: &Path) -> Vec<DiscoveredResource> {
2112    let mut results = Vec::new();
2113    discover_skills_recursive(dir, dir, &mut results);
2114    results
2115}
2116
2117fn discover_skills_recursive(base: &Path, current: &Path, results: &mut Vec<DiscoveredResource>) {
2118    if !current.exists() {
2119        return;
2120    }
2121
2122    let entries = match fs::read_dir(current) {
2123        Ok(e) => e,
2124        Err(_) => return,
2125    };
2126
2127    for entry in entries.flatten() {
2128        let path = entry.path();
2129        let name = entry.file_name();
2130        let name_str = name.to_string_lossy();
2131
2132        if name_str.starts_with('.') || name_str == "node_modules" {
2133            continue;
2134        }
2135
2136        if path.is_dir() {
2137            let skill_file = path.join("SKILL.md");
2138            if skill_file.exists() {
2139                let rel = path.strip_prefix(base).unwrap_or(&path);
2140                results.push(DiscoveredResource {
2141                    kind: ResourceKind::Skill,
2142                    path: skill_file,
2143                    relative_path: rel.join("SKILL.md").to_string_lossy().to_string(),
2144                });
2145            }
2146            discover_skills_recursive(base, &path, results);
2147        }
2148    }
2149}
2150
2151/// Discover prompt template files (.md in prompts/ subdirectory)
2152fn discover_prompts(dir: &Path) -> Vec<DiscoveredResource> {
2153    let prompts_dir = dir.join("prompts");
2154    discover_files_by_ext(
2155        if prompts_dir.exists() {
2156            &prompts_dir
2157        } else {
2158            dir
2159        },
2160        "md",
2161        ResourceKind::Prompt,
2162    )
2163}
2164
2165/// Discover theme files (.json in themes/ subdirectory)
2166fn discover_themes(dir: &Path) -> Vec<DiscoveredResource> {
2167    let themes_dir = dir.join("themes");
2168    discover_files_by_ext(
2169        if themes_dir.exists() {
2170            &themes_dir
2171        } else {
2172            dir
2173        },
2174        "json",
2175        ResourceKind::Theme,
2176    )
2177}
2178
2179/// Recursively find files with a given extension
2180fn discover_files_by_ext(dir: &Path, ext: &str, kind: ResourceKind) -> Vec<DiscoveredResource> {
2181    let mut results = Vec::new();
2182    discover_files_recursive(dir, dir, ext, kind, &mut results);
2183    results
2184}
2185
2186fn discover_files_recursive(
2187    base: &Path,
2188    current: &Path,
2189    ext: &str,
2190    kind: ResourceKind,
2191    results: &mut Vec<DiscoveredResource>,
2192) {
2193    if !current.exists() {
2194        return;
2195    }
2196
2197    let entries = match fs::read_dir(current) {
2198        Ok(e) => e,
2199        Err(_) => return,
2200    };
2201
2202    for entry in entries.flatten() {
2203        let path = entry.path();
2204        let name = entry.file_name();
2205        let name_str = name.to_string_lossy();
2206
2207        if name_str.starts_with('.') || name_str == "node_modules" {
2208            continue;
2209        }
2210
2211        if path.is_dir() {
2212            discover_files_recursive(base, &path, ext, kind, results);
2213        } else if path.extension().and_then(|e| e.to_str()) == Some(ext) {
2214            let rel = path.strip_prefix(base).unwrap_or(&path);
2215            results.push(DiscoveredResource {
2216                kind,
2217                path: path.clone(),
2218                relative_path: rel.to_string_lossy().to_string(),
2219            });
2220        }
2221    }
2222}
2223
2224// ── Utility functions ─────────────────────────────────────────────────
2225
2226/// Recursively copy a directory
2227fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
2228    if !dst.exists() {
2229        fs::create_dir_all(dst)?;
2230    }
2231
2232    for entry in fs::read_dir(src)? {
2233        let entry = entry?;
2234        let src_path = entry.path();
2235        let dst_path = dst.join(entry.file_name());
2236
2237        if src_path.is_dir() {
2238            copy_dir_recursive(&src_path, &dst_path)?;
2239        } else {
2240            fs::copy(&src_path, &dst_path)?;
2241        }
2242    }
2243
2244    Ok(())
2245}
2246
2247/// Compute a SHA-256 hash of a directory's contents for integrity checking
2248fn compute_dir_hash(dir: &Path) -> Option<String> {
2249    let mut hasher = Sha256::new();
2250    let mut files = collect_file_paths(dir);
2251    files.sort();
2252
2253    for file_path in &files {
2254        if let Ok(content) = fs::read(file_path) {
2255            hasher.update(&content);
2256        }
2257    }
2258
2259    let result = hasher.finalize();
2260    Some(format!("sha256-{:x}", result))
2261}
2262
2263/// Collect all file paths in a directory recursively
2264fn collect_file_paths(dir: &Path) -> Vec<PathBuf> {
2265    let mut paths = Vec::new();
2266    if !dir.exists() {
2267        return paths;
2268    }
2269
2270    let entries = match fs::read_dir(dir) {
2271        Ok(e) => e,
2272        Err(_) => return paths,
2273    };
2274
2275    for entry in entries.flatten() {
2276        let path = entry.path();
2277        if path.is_dir() {
2278            paths.extend(collect_file_paths(&path));
2279        } else {
2280            paths.push(path);
2281        }
2282    }
2283
2284    paths
2285}
2286
2287/// Find the single subdirectory inside an extracted archive
2288fn find_single_subdir(dir: &Path) -> Option<PathBuf> {
2289    let entries: Vec<_> = fs::read_dir(dir).ok()?.filter_map(|e| e.ok()).collect();
2290    if entries.len() == 1 && entries[0].path().is_dir() {
2291        Some(entries[0].path())
2292    } else {
2293        None
2294    }
2295}
2296
2297/// Remove empty parent directories up to a root
2298fn prune_empty_parents(target: &Path, root: &Path) {
2299    let mut current = target.parent();
2300    while let Some(dir) = current {
2301        if dir == root || !dir.starts_with(root) {
2302            break;
2303        }
2304        if dir.exists() {
2305            let is_empty = fs::read_dir(dir)
2306                .map(|mut rd| rd.next().is_none())
2307                .unwrap_or(false);
2308            if is_empty {
2309                let _ = fs::remove_dir(dir);
2310            } else {
2311                break;
2312            }
2313        }
2314        current = dir.parent();
2315    }
2316}
2317
2318#[cfg(test)]
2319mod tests {
2320    use super::*;
2321
2322    fn setup_temp_packages_dir() -> (tempfile::TempDir, PathBuf) {
2323        let tmp = tempfile::tempdir().unwrap();
2324        let packages_dir = tmp.path().join("packages");
2325        fs::create_dir_all(&packages_dir).unwrap();
2326        (tmp, packages_dir)
2327    }
2328
2329    fn create_test_package(base: &Path, name: &str, version: &str) -> PathBuf {
2330        let pkg_dir = base.join("source-pkg");
2331        fs::create_dir_all(&pkg_dir).unwrap();
2332
2333        let manifest = PackageManifest {
2334            name: name.to_string(),
2335            version: version.to_string(),
2336            extensions: vec!["ext1.so".to_string()],
2337            skills: vec!["skill-a".to_string()],
2338            prompts: vec![],
2339            themes: vec![],
2340            description: None,
2341            dependencies: BTreeMap::new(),
2342        };
2343
2344        let toml_content = toml::to_string_pretty(&manifest).unwrap();
2345        fs::write(pkg_dir.join(MANIFEST_NAME), toml_content).unwrap();
2346        fs::write(pkg_dir.join("ext1.so"), "fake extension").unwrap();
2347        fs::create_dir_all(pkg_dir.join("skill-a")).unwrap();
2348        fs::write(pkg_dir.join("skill-a").join("SKILL.md"), "# Skill A").unwrap();
2349
2350        pkg_dir
2351    }
2352
2353    fn create_test_package_with_auto_discovery(base: &Path, name: &str, version: &str) -> PathBuf {
2354        let pkg_dir = base.join("source-pkg-auto");
2355        fs::create_dir_all(&pkg_dir).unwrap();
2356
2357        let manifest = PackageManifest {
2358            name: name.to_string(),
2359            version: version.to_string(),
2360            extensions: vec![],
2361            skills: vec![],
2362            prompts: vec![],
2363            themes: vec![],
2364            description: None,
2365            dependencies: BTreeMap::new(),
2366        };
2367        let toml_content = toml::to_string_pretty(&manifest).unwrap();
2368        fs::write(pkg_dir.join(MANIFEST_NAME), toml_content).unwrap();
2369
2370        fs::write(pkg_dir.join("myext.so"), "extension").unwrap();
2371        fs::create_dir_all(pkg_dir.join("my-skill")).unwrap();
2372        fs::write(pkg_dir.join("my-skill").join("SKILL.md"), "# My Skill").unwrap();
2373        fs::create_dir_all(pkg_dir.join("prompts")).unwrap();
2374        fs::write(pkg_dir.join("prompts").join("review.md"), "# Review").unwrap();
2375        fs::create_dir_all(pkg_dir.join("themes")).unwrap();
2376        fs::write(pkg_dir.join("themes").join("dark.json"), "{}").unwrap();
2377
2378        pkg_dir
2379    }
2380
2381    #[test]
2382    fn test_install_and_list() {
2383        let (tmp, packages_dir) = setup_temp_packages_dir();
2384
2385        let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2386        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2387
2388        let manifest = mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2389        assert_eq!(manifest.name, "test-pkg");
2390        assert_eq!(manifest.version, "1.0.0");
2391
2392        let installed = mgr.list();
2393        assert_eq!(installed.len(), 1);
2394        assert_eq!(installed[0].name, "test-pkg");
2395    }
2396
2397    #[test]
2398    fn test_uninstall() {
2399        let (tmp, packages_dir) = setup_temp_packages_dir();
2400
2401        let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2402        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2403
2404        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2405        assert!(mgr.is_installed("test-pkg"));
2406
2407        mgr.uninstall("test-pkg").unwrap();
2408        assert!(!mgr.is_installed("test-pkg"));
2409        assert!(mgr.list().is_empty());
2410    }
2411
2412    #[test]
2413    fn test_uninstall_not_installed() {
2414        let (_tmp, packages_dir) = setup_temp_packages_dir();
2415        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2416
2417        let result = mgr.uninstall("nonexistent");
2418        assert!(result.is_err());
2419    }
2420
2421    #[test]
2422    fn test_install_scoped_package() {
2423        let (tmp, packages_dir) = setup_temp_packages_dir();
2424
2425        let pkg_dir = create_test_package(tmp.path(), "@foo/oxi-tools", "2.0.0");
2426        let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
2427
2428        let manifest = mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2429        assert_eq!(manifest.name, "@foo/oxi-tools");
2430
2431        let expected_dir = packages_dir.join("foo-oxi-tools");
2432        assert!(expected_dir.exists());
2433    }
2434
2435    #[test]
2436    fn test_reinstall_overwrites() {
2437        let (tmp, packages_dir) = setup_temp_packages_dir();
2438
2439        let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2440        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2441
2442        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2443
2444        let pkg_dir_v2 = tmp.path().join("source-pkg-v2");
2445        fs::create_dir_all(&pkg_dir_v2).unwrap();
2446        let manifest_v2 = PackageManifest {
2447            name: "test-pkg".to_string(),
2448            version: "2.0.0".to_string(),
2449            extensions: vec![],
2450            skills: vec![],
2451            prompts: vec![],
2452            themes: vec![],
2453            description: None,
2454            dependencies: BTreeMap::new(),
2455        };
2456        fs::write(
2457            pkg_dir_v2.join(MANIFEST_NAME),
2458            toml::to_string_pretty(&manifest_v2).unwrap(),
2459        )
2460        .unwrap();
2461
2462        mgr.install(pkg_dir_v2.to_str().unwrap()).unwrap();
2463
2464        let installed = mgr.list();
2465        assert_eq!(installed.len(), 1);
2466        assert_eq!(installed[0].version, "2.0.0");
2467    }
2468
2469    #[test]
2470    fn test_empty_packages_dir() {
2471        let (_tmp, packages_dir) = setup_temp_packages_dir();
2472        let mgr = PackageManager::with_dir(packages_dir).unwrap();
2473        assert!(mgr.list().is_empty());
2474    }
2475
2476    #[test]
2477    fn test_packages_dir_not_exists() {
2478        let tmp = tempfile::tempdir().unwrap();
2479        let nonexistent = tmp.path().join("does-not-exist");
2480        let mgr = PackageManager::with_dir(nonexistent).unwrap();
2481        assert!(mgr.list().is_empty());
2482    }
2483
2484    #[test]
2485    fn test_discover_resources_explicit() {
2486        let (tmp, packages_dir) = setup_temp_packages_dir();
2487
2488        let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2489        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2490        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2491
2492        let resources = mgr.discover_resources("test-pkg").unwrap();
2493        assert_eq!(resources.len(), 2);
2494
2495        let extensions: Vec<_> = resources
2496            .iter()
2497            .filter(|r| r.kind == ResourceKind::Extension)
2498            .collect();
2499        let skills: Vec<_> = resources
2500            .iter()
2501            .filter(|r| r.kind == ResourceKind::Skill)
2502            .collect();
2503        assert_eq!(extensions.len(), 1);
2504        assert_eq!(skills.len(), 1);
2505    }
2506
2507    #[test]
2508    fn test_discover_resources_auto() {
2509        let (tmp, packages_dir) = setup_temp_packages_dir();
2510
2511        let pkg_dir = create_test_package_with_auto_discovery(tmp.path(), "auto-pkg", "1.0.0");
2512        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2513        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2514
2515        let resources = mgr.discover_resources("auto-pkg").unwrap();
2516
2517        let ext_count = resources
2518            .iter()
2519            .filter(|r| r.kind == ResourceKind::Extension)
2520            .count();
2521        let skill_count = resources
2522            .iter()
2523            .filter(|r| r.kind == ResourceKind::Skill)
2524            .count();
2525        let prompt_count = resources
2526            .iter()
2527            .filter(|r| r.kind == ResourceKind::Prompt)
2528            .count();
2529        let theme_count = resources
2530            .iter()
2531            .filter(|r| r.kind == ResourceKind::Theme)
2532            .count();
2533
2534        assert!(
2535            ext_count >= 1,
2536            "Expected at least 1 extension, got {}",
2537            ext_count
2538        );
2539        assert!(
2540            skill_count >= 1,
2541            "Expected at least 1 skill, got {}",
2542            skill_count
2543        );
2544        assert!(
2545            prompt_count >= 1,
2546            "Expected at least 1 prompt, got {}",
2547            prompt_count
2548        );
2549        assert!(
2550            theme_count >= 1,
2551            "Expected at least 1 theme, got {}",
2552            theme_count
2553        );
2554    }
2555
2556    #[test]
2557    fn test_resource_counts() {
2558        let (tmp, packages_dir) = setup_temp_packages_dir();
2559
2560        let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2561        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2562        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2563
2564        let counts = mgr.resource_counts("test-pkg").unwrap();
2565        assert_eq!(counts.extensions, 1);
2566        assert_eq!(counts.skills, 1);
2567        assert_eq!(counts.prompts, 0);
2568        assert_eq!(counts.themes, 0);
2569    }
2570
2571    #[test]
2572    fn test_resource_counts_display() {
2573        let counts = ResourceCounts {
2574            extensions: 2,
2575            skills: 1,
2576            prompts: 0,
2577            themes: 3,
2578        };
2579        assert_eq!(counts.to_string(), "2 ext, 1 skill, 3 theme");
2580
2581        let empty = ResourceCounts::default();
2582        assert_eq!(empty.to_string(), "-");
2583    }
2584
2585    #[test]
2586    fn test_resource_kind_display() {
2587        assert_eq!(ResourceKind::Extension.to_string(), "extension");
2588        assert_eq!(ResourceKind::Skill.to_string(), "skill");
2589        assert_eq!(ResourceKind::Prompt.to_string(), "prompt");
2590        assert_eq!(ResourceKind::Theme.to_string(), "theme");
2591    }
2592
2593    #[test]
2594    fn test_get_install_dir() {
2595        let (tmp, packages_dir) = setup_temp_packages_dir();
2596
2597        let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2598        let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
2599        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2600
2601        let dir = mgr.get_install_dir("test-pkg").unwrap();
2602        assert!(dir.exists());
2603        assert!(dir.join(MANIFEST_NAME).exists());
2604
2605        assert!(mgr.get_install_dir("nonexistent").is_none());
2606    }
2607
2608    #[test]
2609    fn test_discover_resources_not_installed() {
2610        let (_tmp, packages_dir) = setup_temp_packages_dir();
2611        let mgr = PackageManager::with_dir(packages_dir).unwrap();
2612
2613        let result = mgr.discover_resources("nonexistent");
2614        assert!(result.is_err());
2615    }
2616
2617    #[test]
2618    fn test_update_not_installed() {
2619        let (_tmp, packages_dir) = setup_temp_packages_dir();
2620        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2621
2622        let result = mgr.update("nonexistent");
2623        assert!(result.is_err());
2624    }
2625
2626    // ── Source parsing tests ──────────────────────────────────────────
2627
2628    #[test]
2629    fn test_parse_npm_source() {
2630        let parsed = ParsedSource::parse("npm:express@4.18.0");
2631        match parsed {
2632            ParsedSource::Npm { spec, name, pinned } => {
2633                assert_eq!(spec, "express@4.18.0");
2634                assert_eq!(name, "express");
2635                assert!(pinned);
2636            }
2637            _ => panic!("Expected Npm source"),
2638        }
2639
2640        let parsed = ParsedSource::parse("npm:lodash");
2641        match parsed {
2642            ParsedSource::Npm { name, pinned, .. } => {
2643                assert_eq!(name, "lodash");
2644                assert!(!pinned);
2645            }
2646            _ => panic!("Expected Npm source"),
2647        }
2648    }
2649
2650    #[test]
2651    fn test_parse_git_source() {
2652        let parsed = ParsedSource::parse("https://github.com/org/repo.git");
2653        match parsed {
2654            ParsedSource::Git {
2655                host, path, ref_, ..
2656            } => {
2657                assert_eq!(host, "github.com");
2658                assert_eq!(path, "org/repo");
2659                assert!(ref_.is_none());
2660            }
2661            _ => panic!("Expected Git source"),
2662        }
2663
2664        let parsed = ParsedSource::parse("https://github.com/org/repo.git@v1.0.0");
2665        match parsed {
2666            ParsedSource::Git { path, ref_, .. } => {
2667                assert_eq!(path, "org/repo");
2668                assert_eq!(ref_.as_deref(), Some("v1.0.0"));
2669            }
2670            _ => panic!("Expected Git source"),
2671        }
2672    }
2673
2674    #[test]
2675    fn test_parse_github_shorthand() {
2676        let parsed = ParsedSource::parse("github:org/repo@main");
2677        match parsed {
2678            ParsedSource::Git {
2679                host, path, ref_, ..
2680            } => {
2681                assert_eq!(host, "github.com");
2682                assert_eq!(path, "org/repo");
2683                assert_eq!(ref_.as_deref(), Some("main"));
2684            }
2685            _ => panic!("Expected Git source"),
2686        }
2687    }
2688
2689    #[test]
2690    fn test_parse_local_source() {
2691        let parsed = ParsedSource::parse("/path/to/package");
2692        match parsed {
2693            ParsedSource::Local { path } => {
2694                assert_eq!(path, "/path/to/package");
2695            }
2696            _ => panic!("Expected Local source"),
2697        }
2698
2699        let parsed = ParsedSource::parse("./relative/path");
2700        match parsed {
2701            ParsedSource::Local { path } => {
2702                assert_eq!(path, "./relative/path");
2703            }
2704            _ => panic!("Expected Local source"),
2705        }
2706    }
2707
2708    #[test]
2709    fn test_parse_url_source() {
2710        let parsed = ParsedSource::parse("https://example.com/pkg.tar.gz");
2711        match parsed {
2712            ParsedSource::Url { url } => {
2713                assert_eq!(url, "https://example.com/pkg.tar.gz");
2714            }
2715            _ => panic!("Expected Url source"),
2716        }
2717    }
2718
2719    #[test]
2720    fn test_source_identity() {
2721        let npm = ParsedSource::parse("npm:express@4.18.0");
2722        assert_eq!(npm.identity(), "npm:express");
2723
2724        let git = ParsedSource::parse("https://github.com/org/repo.git");
2725        assert_eq!(git.identity(), "git:github.com/org/repo");
2726
2727        let local = ParsedSource::parse("/path/to/pkg");
2728        assert_eq!(local.identity(), "local:/path/to/pkg");
2729    }
2730
2731    #[test]
2732    fn test_parse_npm_spec() {
2733        let (name, pinned) = parse_npm_spec("express@4.18.0");
2734        assert_eq!(name, "express");
2735        assert!(pinned);
2736
2737        let (name, pinned) = parse_npm_spec("express");
2738        assert_eq!(name, "express");
2739        assert!(!pinned);
2740
2741        let (name, pinned) = parse_npm_spec("@scope/pkg@1.0.0");
2742        assert_eq!(name, "@scope/pkg");
2743        assert!(pinned);
2744    }
2745
2746    // ── Lockfile tests ────────────────────────────────────────────────
2747
2748    #[test]
2749    fn test_lockfile_roundtrip() {
2750        let (tmp, _) = setup_temp_packages_dir();
2751        let lock_path = tmp.path().join(LOCKFILE_NAME);
2752
2753        let mut lock = Lockfile::new();
2754        lock.insert(LockEntry {
2755            source: "npm:express@4.18.0".to_string(),
2756            name: "express".to_string(),
2757            version: "4.18.0".to_string(),
2758            integrity: Some("sha256-abc123".to_string()),
2759            scope: SourceScope::User,
2760            source_type: "npm".to_string(),
2761            dependencies: BTreeMap::new(),
2762        });
2763
2764        lock.write(&lock_path).unwrap();
2765
2766        let loaded = Lockfile::read(&lock_path).unwrap().unwrap();
2767        assert_eq!(loaded.packages.len(), 1);
2768        assert_eq!(loaded.packages["express"].version, "4.18.0");
2769        assert_eq!(
2770            loaded.packages["express"].integrity.as_deref(),
2771            Some("sha256-abc123")
2772        );
2773    }
2774
2775    #[test]
2776    fn test_lockfile_install_roundtrip() {
2777        let (tmp, packages_dir) = setup_temp_packages_dir();
2778        let pkg_dir = create_test_package(tmp.path(), "locked-pkg", "1.0.0");
2779
2780        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2781        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2782
2783        // Lockfile should have been written
2784        let lock_path = mgr.packages_dir().join(LOCKFILE_NAME);
2785        assert!(lock_path.exists());
2786
2787        let lock = Lockfile::read(&lock_path).unwrap().unwrap();
2788        assert!(lock.contains("locked-pkg"));
2789        let entry = lock.get("locked-pkg").unwrap();
2790        assert_eq!(entry.version, "1.0.0");
2791    }
2792
2793    // ── Validation tests ──────────────────────────────────────────────
2794
2795    #[test]
2796    fn test_validate_valid_package() {
2797        let (tmp, _) = setup_temp_packages_dir();
2798        let pkg_dir = create_test_package(tmp.path(), "valid-pkg", "1.0.0");
2799        let warnings = PackageManager::validate_package(&pkg_dir).unwrap();
2800        // Should have minimal warnings (maybe just about .gitignore)
2801        assert!(
2802            warnings.len() <= 1,
2803            "Expected <= 1 warning, got {:?}",
2804            warnings
2805        );
2806    }
2807
2808    #[test]
2809    fn test_validate_empty_dir() {
2810        let tmp = tempfile::tempdir().unwrap();
2811        let empty_dir = tmp.path().join("empty-pkg");
2812        fs::create_dir_all(&empty_dir).unwrap();
2813        let warnings = PackageManager::validate_package(&empty_dir).unwrap();
2814        assert!(!warnings.is_empty());
2815    }
2816
2817    // ── Dependency tests ──────────────────────────────────────────────
2818
2819    #[test]
2820    fn test_resolve_dependencies() {
2821        let (tmp, packages_dir) = setup_temp_packages_dir();
2822
2823        // Create a package with dependencies
2824        let pkg_dir = tmp.path().join("dep-pkg");
2825        fs::create_dir_all(&pkg_dir).unwrap();
2826        let mut deps = BTreeMap::new();
2827        deps.insert("lodash".to_string(), "^4.0.0".to_string());
2828        deps.insert("nonexistent-pkg".to_string(), "^1.0.0".to_string());
2829
2830        let manifest = PackageManifest {
2831            name: "dep-pkg".to_string(),
2832            version: "1.0.0".to_string(),
2833            extensions: vec![],
2834            skills: vec![],
2835            prompts: vec![],
2836            themes: vec![],
2837            description: None,
2838            dependencies: deps,
2839        };
2840        fs::write(
2841            pkg_dir.join(MANIFEST_NAME),
2842            toml::to_string_pretty(&manifest).unwrap(),
2843        )
2844        .unwrap();
2845
2846        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2847        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2848
2849        let missing = mgr.resolve_dependencies();
2850        assert_eq!(missing.len(), 1);
2851        assert_eq!(missing[0].0, "dep-pkg");
2852        assert!(
2853            missing[0].1.contains(&"lodash".to_string())
2854                || missing[0].1.contains(&"nonexistent-pkg".to_string())
2855        );
2856    }
2857
2858    // ── Version tests ─────────────────────────────────────────────────
2859
2860    #[test]
2861    fn test_version_satisfies() {
2862        let (tmp, packages_dir) = setup_temp_packages_dir();
2863        let pkg_dir = create_test_package(tmp.path(), "ver-pkg", "1.2.3");
2864        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2865        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2866
2867        assert!(mgr.version_satisfies("ver-pkg", "^1.0.0"));
2868        assert!(mgr.version_satisfies("ver-pkg", ">=1.0.0"));
2869        assert!(!mgr.version_satisfies("ver-pkg", "^2.0.0"));
2870        assert!(!mgr.version_satisfies("ver-pkg", "<1.0.0"));
2871    }
2872
2873    #[test]
2874    fn test_get_installed_version() {
2875        let (tmp, packages_dir) = setup_temp_packages_dir();
2876        let pkg_dir = create_test_package(tmp.path(), "ver-pkg", "3.1.4");
2877        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2878        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2879
2880        assert_eq!(mgr.get_installed_version("ver-pkg"), Some("3.1.4"));
2881        assert_eq!(mgr.get_installed_version("nonexistent"), None);
2882    }
2883
2884    // ── Resolve tests ─────────────────────────────────────────────────
2885
2886    #[test]
2887    fn test_resolve() {
2888        let (tmp, packages_dir) = setup_temp_packages_dir();
2889        let pkg_dir = create_test_package(tmp.path(), "resolve-pkg", "1.0.0");
2890        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2891        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2892
2893        let resolved = mgr.resolve();
2894        assert!(!resolved.extensions.is_empty() || !resolved.skills.is_empty());
2895    }
2896
2897    // ── Progress callback tests ───────────────────────────────────────
2898
2899    #[test]
2900    fn test_progress_callback() {
2901        use std::sync::{Arc, Mutex};
2902
2903        let events: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
2904        let events_clone = events.clone();
2905
2906        let (tmp, packages_dir) = setup_temp_packages_dir();
2907        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2908
2909        mgr.set_progress_callback(Box::new(move |event| {
2910            let mut e = events_clone.lock().unwrap();
2911            e.push(format!("{:?}:{:?}", event.event_type, event.action));
2912        }));
2913
2914        let pkg_dir = create_test_package(tmp.path(), "progress-pkg", "1.0.0");
2915        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2916
2917        // install_local doesn't use with_progress, so no events expected from install()
2918        // Just verify the progress event mechanism exists and doesn't panic
2919        let _event_count = events.lock().unwrap().len();
2920    }
2921
2922    #[test]
2923    fn test_list_configured() {
2924        let (tmp, packages_dir) = setup_temp_packages_dir();
2925        let pkg_dir = create_test_package(tmp.path(), "cfg-pkg", "1.0.0");
2926        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2927        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2928
2929        let configured = mgr.list_configured();
2930        assert_eq!(configured.len(), 1);
2931        assert!(configured[0].source.contains("source-pkg"));
2932        // source comes from lockfile, might be the local path
2933    }
2934}