Skip to main content

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