Skip to main content

oxicode/storage/packages/
manager.rs

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