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