Skip to main content

oxicode/storage/packages/
manager.rs

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