Skip to main content

cmx_core/
skill_install.rs

1//! High-level, embeddable skill-installation API.
2//!
3//! A tool bundles its companion skill (as [`BundledSkill`]) and calls
4//! [`SkillInstaller`] to install, query, or remove it — without knowing about
5//! any cmx internals.
6//!
7//! ```no_run
8//! # use anyhow::Result;
9//! # fn main() -> Result<()> {
10//! use cmx_core::production::ProductionContext;
11//! use cmx_core::skill_install::{BundledSkill, Scope, SkillInstaller, ToolIdentity};
12//!
13//! let skill = BundledSkill::single_md("---\nversion: 1.2.0\n---\n# My skill\n");
14//! let installer = SkillInstaller::new(ToolIdentity::new("mytool", "1.2.0"));
15//! let prod_ctx = ProductionContext::claude()?;
16//! let ctx = prod_ctx.ctx();
17//! let plan = installer.plan(&skill, Scope::Global, false, &ctx)?;
18//! println!("{plan}");
19//! let report = installer.apply(&skill, &plan, &ctx)?;
20//! println!("{report}");
21//! # Ok(())
22//! # }
23//! ```
24
25use anyhow::{Result, bail};
26use std::cmp::Ordering;
27use std::collections::BTreeSet;
28use std::path::PathBuf;
29
30use crate::checksum;
31use crate::config;
32use crate::context::AppContext;
33use crate::lockfile;
34use crate::platform::Platform;
35use crate::platform_iter;
36use crate::skill_fs::{self, SkillFile};
37use crate::targets;
38use crate::types::{ArtifactKind, InstallScope, LockEntry, LockSource, SourceEntry, SourceType};
39
40// ---------------------------------------------------------------------------
41// Public API types
42// ---------------------------------------------------------------------------
43
44/// Identity of the embedding tool — name and semver version string.
45#[derive(Debug, Clone)]
46pub struct ToolIdentity {
47    pub name: String,
48    pub version: String,
49}
50
51impl ToolIdentity {
52    /// Construct a tool identity from a name and version string.
53    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
54        Self {
55            name: name.into(),
56            version: version.into(),
57        }
58    }
59}
60
61/// A skill bundled inside a tool binary (via `include_str!` or similar).
62pub struct BundledSkill {
63    pub files: Vec<SkillFile>,
64}
65
66impl BundledSkill {
67    /// Construct from a list of files (e.g. assembled by the embedding tool from
68    /// `include_str!` or `include_bytes!` calls).
69    pub fn from_files(files: Vec<SkillFile>) -> Self {
70        Self { files }
71    }
72
73    /// Convenience constructor for the common single-`SKILL.md` case.
74    ///
75    /// Builds a bundle containing exactly one file at path `SKILL.md` with the
76    /// given content. Use `from_files` when the skill includes additional files.
77    pub fn single_md(content: &str) -> Self {
78        Self {
79            files: vec![SkillFile {
80                rel_path: PathBuf::from("SKILL.md"),
81                bytes: content.as_bytes().to_vec(),
82            }],
83        }
84    }
85
86    /// Returns `true` when the bundle contains a `SKILL.md` at the root level
87    /// (i.e. `rel_path == "SKILL.md"`).
88    pub fn has_skill_md(&self) -> bool {
89        self.files.iter().any(|f| f.rel_path.as_os_str() == "SKILL.md")
90    }
91}
92
93/// Installation scope — global (user-wide) or local (project-scoped).
94#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
95pub enum Scope {
96    #[default]
97    Global,
98    Local,
99}
100
101impl Scope {
102    pub fn to_install_scope(self) -> InstallScope {
103        match self {
104            Scope::Global => InstallScope::Global,
105            Scope::Local => InstallScope::Local,
106        }
107    }
108}
109
110// ---------------------------------------------------------------------------
111// Plan types
112// ---------------------------------------------------------------------------
113
114/// The action to take for a single target platform during an install.
115///
116/// # Non-exhaustive
117///
118/// This enum is `#[non_exhaustive]`: new action variants may be added in
119/// future minor releases. Embedders should render actions via the `Display`
120/// impl on `Report`/`InstallPlan` or match on specific variants they care
121/// about with a catch-all `_` arm. The `will_write()` and `is_blocked()`
122/// helpers cover the two common branching points without requiring exhaustive
123/// matching.
124#[non_exhaustive]
125#[derive(Debug, Clone)]
126pub enum TargetAction {
127    /// First-time install (no existing copy).
128    Install,
129    /// Overwrite an older installed version.
130    Update {
131        /// The previously installed version string (if known).
132        from: Option<String>,
133    },
134    /// Already installed at the same version and checksum — nothing to do.
135    Skip,
136    /// Same version but the on-disk content differs from the bundled content,
137    /// and `force` was not requested.
138    DriftedSkip { installed: String },
139    /// The installed version is newer than the bundled version, and `force` was
140    /// not requested.
141    RefuseNewer { installed: String },
142    /// The installed version is newer, but `force` was requested — downgrade.
143    Downgrade { from: String },
144}
145
146impl TargetAction {
147    /// Whether this action will write files to disk.
148    pub fn will_write(&self) -> bool {
149        matches!(self, Self::Install | Self::Update { .. } | Self::Downgrade { .. })
150    }
151
152    /// Whether this action blocks the install from proceeding.
153    pub fn is_blocked(&self) -> bool {
154        matches!(self, Self::RefuseNewer { .. })
155    }
156}
157
158/// A single file to be written, with its relative and absolute destination paths.
159#[derive(Debug, Clone)]
160pub struct PlannedFile {
161    /// Relative path within the skill directory (e.g. `SKILL.md`).
162    pub rel_path: PathBuf,
163    /// Absolute (or scope-relative) destination path on disk.
164    pub dest_path: PathBuf,
165}
166
167/// The plan for a single target platform.
168#[derive(Debug)]
169pub struct TargetPlan {
170    pub platform: Platform,
171    pub scope: InstallScope,
172    pub dest_dir: PathBuf,
173    pub files: Vec<PlannedFile>,
174    pub action: TargetAction,
175    /// Whether this platform is in the cmx-managed set.
176    pub cmx_managed: bool,
177}
178
179/// The full installation plan — computed from source metadata, with no writes.
180#[derive(Debug)]
181pub struct InstallPlan {
182    pub tool: ToolIdentity,
183    pub scope: InstallScope,
184    pub source_checksum: String,
185    /// Whether cmx is managing this machine (config or non-empty lock exists).
186    pub cmx_present: bool,
187    pub force: bool,
188    pub targets: Vec<TargetPlan>,
189}
190
191impl InstallPlan {
192    /// Returns `true` if any target action is blocked (e.g. `RefuseNewer`).
193    pub fn is_blocked(&self) -> bool {
194        self.targets.iter().any(|t| t.action.is_blocked())
195    }
196
197    /// The number of targets that will write files to disk.
198    pub fn write_count(&self) -> usize {
199        self.targets.iter().filter(|t| t.action.will_write()).count()
200    }
201}
202
203impl std::fmt::Display for InstallPlan {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        writeln!(f, "Install plan for {} v{}", self.tool.name, self.tool.version)?;
206        writeln!(f, "  scope: {}", self.scope.label())?;
207        writeln!(f, "  checksum: {}", self.source_checksum)?;
208        for target in &self.targets {
209            writeln!(
210                f,
211                "  {} → {} ({})",
212                target.platform,
213                target.dest_dir.display(),
214                format_action(&target.action)
215            )?;
216        }
217        Ok(())
218    }
219}
220
221fn format_action(action: &TargetAction) -> String {
222    match action {
223        TargetAction::Install => "install".to_string(),
224        TargetAction::Update { from } => {
225            format!("update from {}", from.as_deref().unwrap_or("?"))
226        }
227        TargetAction::Skip => "skip (up to date)".to_string(),
228        TargetAction::DriftedSkip { installed } => {
229            format!("skip (drifted from {installed})")
230        }
231        TargetAction::RefuseNewer { installed } => {
232            format!("refuse (installed {installed} is newer)")
233        }
234        TargetAction::Downgrade { from } => format!("downgrade from {from}"),
235    }
236}
237
238// ---------------------------------------------------------------------------
239// Apply result types
240// ---------------------------------------------------------------------------
241
242/// The outcome for a single target platform after `apply`.
243///
244/// Both written and skipped targets appear in `Report::targets`; the `action`
245/// field distinguishes them. Use `Report::applied()` / `Report::skipped()` for
246/// filtered views.
247#[derive(Debug)]
248pub struct TargetOutcome {
249    pub platform: Platform,
250    pub dest_dir: PathBuf,
251    pub action: TargetAction,
252    /// Number of files written to disk (0 for skipped targets).
253    pub files_written: usize,
254    /// Checksum recorded in the lock file. `Some` for written targets, `None`
255    /// for skipped targets (no lock entry was touched).
256    pub installed_checksum: Option<String>,
257}
258
259/// The full report returned by [`SkillInstaller::apply`].
260///
261/// All targets — both written and skipped — are captured in `targets` so that
262/// skipped targets retain their `dest_dir` and the `action` discriminant
263/// distinguishes an ordinary up-to-date skip from a `DriftedSkip` (local edits
264/// preserved). Use the `applied()` and `skipped()` iterators for filtered views.
265#[derive(Debug)]
266pub struct Report {
267    pub tool: ToolIdentity,
268    pub scope: InstallScope,
269    /// Every platform that was considered, written or not.
270    pub targets: Vec<TargetOutcome>,
271    /// Whether a source entry was registered in sources.json.
272    pub source_registered: bool,
273}
274
275impl Report {
276    /// Targets where files were written to disk (Install / Update / Downgrade).
277    pub fn applied(&self) -> impl Iterator<Item = &TargetOutcome> {
278        self.targets.iter().filter(|o| o.action.will_write())
279    }
280
281    /// Targets where no files were written (`Skip` / `DriftedSkip` / `RefuseNewer`).
282    pub fn skipped(&self) -> impl Iterator<Item = &TargetOutcome> {
283        self.targets.iter().filter(|o| !o.action.will_write())
284    }
285}
286
287impl std::fmt::Display for Report {
288    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289        writeln!(
290            f,
291            "Installed {} v{} ({})",
292            self.tool.name,
293            self.tool.version,
294            self.scope.label()
295        )?;
296        for outcome in &self.targets {
297            writeln!(
298                f,
299                "  {} → {} ({})",
300                outcome.platform,
301                outcome.dest_dir.display(),
302                format_action(&outcome.action)
303            )?;
304        }
305        if self.source_registered {
306            writeln!(f, "  (registered as cmx source)")?;
307        }
308        Ok(())
309    }
310}
311
312impl std::fmt::Display for RemoveReport {
313    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314        writeln!(f, "Removed {} ({})", self.tool_name, self.scope.label())?;
315        for platform in &self.platforms_cleared {
316            writeln!(f, "  {platform} lock entry cleared")?;
317        }
318        for dir in &self.removed_dirs {
319            writeln!(f, "  removed: {}", dir.display())?;
320        }
321        if self.source_unregistered {
322            writeln!(f, "  unregistered from cmx sources")?;
323        }
324        writeln!(f, "  note: cmx-lock.json left on disk (shared with other tools)")?;
325        Ok(())
326    }
327}
328
329// ---------------------------------------------------------------------------
330// Status types
331// ---------------------------------------------------------------------------
332
333/// Status of a skill on a single platform.
334#[derive(Debug)]
335pub struct TargetStatus {
336    pub platform: Platform,
337    pub installed: bool,
338    pub installed_version: Option<String>,
339    pub drifted: bool,
340    pub tracked: bool,
341}
342
343/// Status of a skill across all relevant platforms.
344#[derive(Debug)]
345pub struct Status {
346    pub tool_name: String,
347    pub scope: InstallScope,
348    pub targets: Vec<TargetStatus>,
349}
350
351// ---------------------------------------------------------------------------
352// Remove result types
353// ---------------------------------------------------------------------------
354
355/// The result of removing a skill.
356#[derive(Debug)]
357pub struct RemoveReport {
358    pub tool_name: String,
359    pub scope: InstallScope,
360    pub removed_dirs: Vec<PathBuf>,
361    pub platforms_cleared: Vec<Platform>,
362    pub source_unregistered: bool,
363    pub was_on_disk: bool,
364    pub was_tracked: bool,
365}
366
367// ---------------------------------------------------------------------------
368// SkillInstaller
369// ---------------------------------------------------------------------------
370
371/// High-level skill lifecycle manager for embedding tools.
372pub struct SkillInstaller {
373    tool: ToolIdentity,
374}
375
376impl SkillInstaller {
377    /// Create a new installer for the given tool identity.
378    pub fn new(tool: ToolIdentity) -> Self {
379        Self { tool }
380    }
381
382    /// Compute a dry-run install plan without writing anything.
383    ///
384    /// Fails if the bundle does not contain a `SKILL.md`.
385    pub fn plan(
386        &self,
387        skill: &BundledSkill,
388        scope: Scope,
389        force: bool,
390        ctx: &AppContext<'_>,
391    ) -> Result<InstallPlan> {
392        if !skill.has_skill_md() {
393            bail!("BundledSkill for '{}' is missing SKILL.md", self.tool.name);
394        }
395
396        let source_checksum = skill_fs::checksum_bundled(&skill.files);
397        let install_scope = scope.to_install_scope();
398
399        let platform_targets =
400            targets::resolve_targets(None, ArtifactKind::Skill, install_scope, ctx)?;
401
402        let cmx_managed = config::managed_platforms(ctx.fs, ctx.paths)?.is_some();
403        let cmx_present = cmx_managed || {
404            // Check whether any platform has a non-empty lock file
405            platform_iter::views_for(ctx.paths, platform_iter::all(), ArtifactKind::Skill).any(
406                |view| {
407                    lockfile::load(install_scope, ctx.fs, &view.paths)
408                        .ok()
409                        .is_some_and(|l| !l.packages.is_empty())
410                },
411            )
412        };
413
414        let mut target_plans = Vec::new();
415        // Track which dest_dirs we've already planned files for (shared dirs).
416        // For platforms that share a dest_dir (e.g. .agents/skills), we still
417        // produce a TargetPlan per platform but with the same files list.
418        // Apply will dedup writes by dest_dir.
419
420        for &platform in &platform_targets {
421            let pv = ctx.paths.with_platform(platform);
422            let dest_dir = pv.require_install_dir(ArtifactKind::Skill, install_scope)?;
423            let skill_dest = dest_dir.join(&self.tool.name);
424
425            // Build planned files
426            let files: Vec<PlannedFile> = skill
427                .files
428                .iter()
429                .map(|f| PlannedFile {
430                    rel_path: f.rel_path.clone(),
431                    dest_path: skill_dest.join(&f.rel_path),
432                })
433                .collect();
434
435            // Determine the action for this platform
436            let lock = lockfile::load(install_scope, ctx.fs, &pv)?;
437            let action = if let Some(entry) = lock.packages.get(&self.tool.name) {
438                decide_action_for_entry(
439                    entry,
440                    &self.tool.version,
441                    &source_checksum,
442                    force,
443                    &skill_dest,
444                    ctx,
445                )
446            } else if ctx.fs.exists(&skill_dest) {
447                // On disk but not tracked: treat as Install (untracked copy)
448                TargetAction::Install
449            } else {
450                TargetAction::Install
451            };
452
453            target_plans.push(TargetPlan {
454                platform,
455                scope: install_scope,
456                dest_dir: skill_dest,
457                files,
458                action,
459                cmx_managed,
460            });
461        }
462
463        Ok(InstallPlan {
464            tool: self.tool.clone(),
465            scope: install_scope,
466            source_checksum,
467            cmx_present,
468            force,
469            targets: target_plans,
470        })
471    }
472
473    /// Apply an install plan, writing files and updating lock entries.
474    ///
475    /// Fails if:
476    /// - The plan is blocked (e.g. `RefuseNewer`).
477    /// - The bundled skill's checksum does not match the plan's `source_checksum`
478    ///   (parity guard — ensures the skill passed here is the same one planned).
479    pub fn apply(
480        &self,
481        skill: &BundledSkill,
482        plan: &InstallPlan,
483        ctx: &AppContext<'_>,
484    ) -> Result<Report> {
485        if plan.is_blocked() {
486            bail!(
487                "Install plan for '{}' is blocked. Run with force=true to override.",
488                self.tool.name
489            );
490        }
491
492        // Parity guard: the skill passed here must match the one that was planned.
493        let current_checksum = skill_fs::checksum_bundled(&skill.files);
494        if current_checksum != plan.source_checksum {
495            bail!(
496                "Parity check failed for '{}': the BundledSkill has changed since plan() was called.",
497                self.tool.name
498            );
499        }
500
501        // Collect distinct dest dirs that need writing (dedup shared dirs).
502        let mut dirs_to_write: BTreeSet<PathBuf> = BTreeSet::new();
503        for target in &plan.targets {
504            if target.action.will_write() {
505                dirs_to_write.insert(target.dest_dir.clone());
506            }
507        }
508
509        // Write each distinct dir once.
510        for dir in &dirs_to_write {
511            skill_fs::write_skill_files(dir, &skill.files, ctx.fs)?;
512        }
513
514        let installed_checksum = plan.source_checksum.clone();
515        let installed_at = ctx.clock.now().to_rfc3339();
516
517        let mut targets: Vec<TargetOutcome> = Vec::new();
518
519        for target in &plan.targets {
520            if !target.action.will_write() {
521                targets.push(TargetOutcome {
522                    platform: target.platform,
523                    dest_dir: target.dest_dir.clone(),
524                    action: target.action.clone(),
525                    files_written: 0,
526                    installed_checksum: None,
527                });
528                continue;
529            }
530
531            // Write lock entry for this platform.
532            let pv = ctx.paths.with_platform(target.platform);
533            lockfile::mutate(target.scope, ctx.fs, &pv, |lock| {
534                lock.packages.insert(
535                    self.tool.name.clone(),
536                    build_lock_entry(&self.tool, &installed_checksum, &installed_at),
537                );
538            })?;
539
540            targets.push(TargetOutcome {
541                platform: target.platform,
542                dest_dir: target.dest_dir.clone(),
543                action: target.action.clone(),
544                files_written: target.files.len(),
545                installed_checksum: Some(installed_checksum.clone()),
546            });
547        }
548
549        // Register source if cmx is managing this machine.
550        let source_registered = if plan.cmx_present
551            && config::managed_platforms(ctx.fs, ctx.paths)?.is_some()
552        {
553            let source_name = format!("bundled:{}", self.tool.name);
554            // Materialize a directory under the default artifact home for source tracing.
555            let home =
556                config::resolve_artifact_home(&config::load_config(ctx.fs, ctx.paths)?, ctx.paths);
557            let materialized = home.join("skills").join(&self.tool.name);
558            skill_fs::write_skill_files(&materialized, &skill.files, ctx.fs)?;
559
560            config::mutate_sources(ctx.fs, ctx.paths, |sources| {
561                sources.sources.entry(source_name.clone()).or_insert_with(|| SourceEntry {
562                    source_type: SourceType::Local,
563                    path: Some(materialized.clone()),
564                    url: None,
565                    local_clone: None,
566                    branch: None,
567                    last_updated: Some(ctx.clock.now().to_rfc3339()),
568                });
569                Ok(())
570            })?;
571            true
572        } else {
573            false
574        };
575
576        Ok(Report {
577            tool: self.tool.clone(),
578            scope: plan.scope,
579            targets,
580            source_registered,
581        })
582    }
583
584    /// Query the install status of this skill across relevant platforms.
585    pub fn status(&self, scope: Scope, ctx: &AppContext<'_>) -> Result<Status> {
586        let install_scope = scope.to_install_scope();
587        let platform_targets =
588            targets::resolve_targets(None, ArtifactKind::Skill, install_scope, ctx)?;
589
590        let mut target_statuses = Vec::new();
591        for &platform in &platform_targets {
592            let pv = ctx.paths.with_platform(platform);
593            let skill_dir = pv
594                .install_dir(ArtifactKind::Skill, install_scope)
595                .map(|d| d.join(&self.tool.name));
596
597            let installed = skill_dir.as_ref().is_some_and(|d| ctx.fs.exists(d));
598
599            let lock = lockfile::load(install_scope, ctx.fs, &pv)?;
600            let lock_entry = lock.packages.get(&self.tool.name);
601            let tracked = lock_entry.is_some();
602            let installed_version = lock_entry.and_then(|e| e.version.clone());
603
604            let drifted = if installed && tracked {
605                if let (Some(dir), Some(entry)) = (&skill_dir, lock_entry) {
606                    checksum::is_locally_modified(dir, ArtifactKind::Skill, entry, ctx.fs)
607                        .unwrap_or(false)
608                } else {
609                    false
610                }
611            } else {
612                false
613            };
614
615            target_statuses.push(TargetStatus {
616                platform,
617                installed,
618                installed_version,
619                drifted,
620                tracked,
621            });
622        }
623
624        Ok(Status {
625            tool_name: self.tool.name.clone(),
626            scope: install_scope,
627            targets: target_statuses,
628        })
629    }
630
631    /// Remove this skill from all relevant platforms.
632    pub fn remove(&self, scope: Scope, ctx: &AppContext<'_>) -> Result<RemoveReport> {
633        let install_scope = scope.to_install_scope();
634        let platform_targets = config::managed_or_all_platforms(ctx.fs, ctx.paths)?
635            .into_iter()
636            .filter(|p| p.supports(ArtifactKind::Skill))
637            .collect::<Vec<_>>();
638
639        let mut dirs_to_delete: BTreeSet<PathBuf> = BTreeSet::new();
640        let mut platforms_cleared: Vec<Platform> = Vec::new();
641        let mut was_tracked = false;
642
643        for &platform in &platform_targets {
644            let pv = ctx.paths.with_platform(platform);
645
646            // Collect physical path for deletion.
647            if let Some(dir) = pv.install_dir(ArtifactKind::Skill, install_scope) {
648                let skill_dir = dir.join(&self.tool.name);
649                if ctx.fs.exists(&skill_dir) {
650                    dirs_to_delete.insert(skill_dir);
651                }
652            }
653
654            // Clear lock entry.
655            let lock = lockfile::load(install_scope, ctx.fs, &pv)?;
656            if lock.packages.contains_key(&self.tool.name) {
657                lockfile::mutate(install_scope, ctx.fs, &pv, |l| {
658                    l.packages.remove(&self.tool.name);
659                })?;
660                platforms_cleared.push(platform);
661                was_tracked = true;
662            }
663        }
664
665        let was_on_disk = !dirs_to_delete.is_empty();
666        let removed_dirs: Vec<PathBuf> = dirs_to_delete.into_iter().collect();
667        for dir in &removed_dirs {
668            ctx.fs.remove_dir_all(dir)?;
669        }
670
671        // Remove from sources and materialized home if managed.
672        let source_name = format!("bundled:{}", self.tool.name);
673        let source_unregistered = if let Ok(sources) = config::load_sources(ctx.fs, ctx.paths) {
674            if sources.sources.contains_key(&source_name) {
675                // Also remove the materialized skill directory.
676                if let Some(entry) = sources.sources.get(&source_name)
677                    && let Some(path) = &entry.path
678                    && ctx.fs.exists(path)
679                {
680                    ctx.fs.remove_dir_all(path)?;
681                }
682                config::mutate_sources(ctx.fs, ctx.paths, |s| {
683                    s.sources.remove(&source_name);
684                    Ok(())
685                })?;
686                true
687            } else {
688                false
689            }
690        } else {
691            false
692        };
693
694        Ok(RemoveReport {
695            tool_name: self.tool.name.clone(),
696            scope: install_scope,
697            removed_dirs,
698            platforms_cleared,
699            source_unregistered,
700            was_on_disk,
701            was_tracked,
702        })
703    }
704}
705
706// ---------------------------------------------------------------------------
707// Private helpers
708// ---------------------------------------------------------------------------
709
710/// Compare two semver version strings.
711///
712/// - `None` installed → `Less` (treat as "not installed").
713/// - Both parse → standard semver comparison.
714/// - Either parse fails → string equality: `Equal` if equal, else `Less`.
715fn compare_versions(installed: Option<&str>, bundled: &str) -> Ordering {
716    let Some(inst) = installed else {
717        return Ordering::Less;
718    };
719    match (semver::Version::parse(inst), semver::Version::parse(bundled)) {
720        (Ok(a), Ok(b)) => a.cmp(&b),
721        _ => {
722            if inst == bundled {
723                Ordering::Equal
724            } else {
725                Ordering::Less
726            }
727        }
728    }
729}
730
731/// Decide what action to take for a platform that already has a lock entry.
732fn decide_action_for_entry(
733    entry: &LockEntry,
734    bundled_version: &str,
735    source_checksum: &str,
736    force: bool,
737    skill_dest: &std::path::Path,
738    ctx: &AppContext<'_>,
739) -> TargetAction {
740    let installed_version = entry.version.as_deref();
741    let cmp = compare_versions(installed_version, bundled_version);
742
743    match cmp {
744        Ordering::Less => {
745            // Installed is older → update
746            TargetAction::Update {
747                from: installed_version.map(str::to_string),
748            }
749        }
750        Ordering::Equal => {
751            // Same version — check if content matches
752            if entry.installed_checksum == source_checksum {
753                // Check on-disk state
754                if ctx.fs.exists(skill_dest) {
755                    TargetAction::Skip
756                } else {
757                    // File is gone — reinstall
758                    TargetAction::Install
759                }
760            } else {
761                // Content differs from locked checksum — check on-disk drift
762                if ctx.fs.exists(skill_dest) {
763                    // The on-disk copy may differ from bundled due to edits
764                    TargetAction::DriftedSkip {
765                        installed: installed_version.unwrap_or("unknown").to_string(),
766                    }
767                } else {
768                    TargetAction::Install
769                }
770            }
771        }
772        Ordering::Greater => {
773            // Installed is newer
774            if force {
775                TargetAction::Downgrade {
776                    from: installed_version.unwrap_or("unknown").to_string(),
777                }
778            } else {
779                TargetAction::RefuseNewer {
780                    installed: installed_version.unwrap_or("unknown").to_string(),
781                }
782            }
783        }
784    }
785}
786
787fn build_lock_entry(tool: &ToolIdentity, checksum: &str, installed_at: &str) -> LockEntry {
788    LockEntry {
789        artifact_type: ArtifactKind::Skill,
790        version: Some(tool.version.clone()),
791        installed_at: installed_at.to_string(),
792        source: LockSource {
793            repo: format!("bundled:{}", tool.name),
794            path: format!("skills/{}", tool.name),
795        },
796        source_checksum: checksum.to_string(),
797        installed_checksum: checksum.to_string(),
798    }
799}
800
801// ---------------------------------------------------------------------------
802// Unit tests
803// ---------------------------------------------------------------------------
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808    use crate::gateway::Filesystem as _;
809    use crate::skill_fs::SkillFile;
810    use crate::test_support::TestContext;
811    use crate::types::{CmxConfig, InstallScope};
812    use crate::{checksum, config};
813
814    fn make_file(rel: &str, content: &str) -> SkillFile {
815        SkillFile {
816            rel_path: std::path::PathBuf::from(rel),
817            bytes: content.as_bytes().to_vec(),
818        }
819    }
820
821    fn sample_skill(version: &str) -> BundledSkill {
822        BundledSkill::from_files(vec![
823            make_file("SKILL.md", &format!("---\nversion: {version}\n---\n# Sample skill\n")),
824            make_file("scripts/tool.py", "print('hello')"),
825        ])
826    }
827
828    fn installer(version: &str) -> SkillInstaller {
829        SkillInstaller::new(ToolIdentity {
830            name: "sample".to_string(),
831            version: version.to_string(),
832        })
833    }
834
835    // -----------------------------------------------------------------------
836    // Tests 1–3: skill_fs / checksum parity
837    // -----------------------------------------------------------------------
838
839    #[test]
840    fn checksum_bundled_matches_checksum_dir_after_write() {
841        let t = TestContext::new();
842        let skill = sample_skill("1.0.0");
843        let expected = skill_fs::checksum_bundled(&skill.files);
844        let dest = std::path::PathBuf::from("/dest/sample");
845        skill_fs::write_skill_files(&dest, &skill.files, &t.fs).unwrap();
846        let on_disk = checksum::checksum_dir(&dest, &t.fs).unwrap();
847        assert_eq!(expected, on_disk, "in-memory checksum must match disk checksum");
848    }
849
850    #[test]
851    fn dotfiles_and_transient_excluded_from_write_and_checksum() {
852        let files = vec![
853            make_file("SKILL.md", "# skill"),
854            make_file(".hidden", "hidden"),
855            make_file("node_modules/dep.js", "vendor"),
856        ];
857        let bundled_cs = skill_fs::checksum_bundled(&files);
858
859        // The checksum must only include SKILL.md
860        let only_skill = vec![make_file("SKILL.md", "# skill")];
861        let expected_cs = skill_fs::checksum_bundled(&only_skill);
862        assert_eq!(
863            bundled_cs, expected_cs,
864            "dotfiles and transient must be excluded from checksum"
865        );
866    }
867
868    #[test]
869    fn write_skill_files_creates_nested_dirs() {
870        let t = TestContext::new();
871        let files = vec![
872            make_file("SKILL.md", "# skill"),
873            make_file("scripts/sub/tool.py", "code"),
874        ];
875        skill_fs::write_skill_files(std::path::Path::new("/dest/s"), &files, &t.fs).unwrap();
876        assert!(t.fs.file_exists(std::path::Path::new("/dest/s/SKILL.md")));
877        assert!(t.fs.file_exists(std::path::Path::new("/dest/s/scripts/sub/tool.py")));
878    }
879
880    // -----------------------------------------------------------------------
881    // Tests 4–6: plan() target selection
882    // -----------------------------------------------------------------------
883
884    #[test]
885    fn fresh_machine_produces_single_claude_target_install() {
886        let t = TestContext::new();
887        let ctx = t.ctx();
888        let skill = sample_skill("1.0.0");
889        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
890
891        assert_eq!(plan.targets.len(), 1);
892        assert_eq!(plan.targets[0].platform, Platform::Claude);
893        assert!(matches!(plan.targets[0].action, TargetAction::Install));
894        assert!(!plan.cmx_present);
895    }
896
897    #[test]
898    fn cmx_config_two_platforms_produces_two_targets_cmx_managed() {
899        let t = TestContext::new();
900        let cfg = CmxConfig {
901            platforms: vec![Platform::Claude, Platform::Codex],
902            ..Default::default()
903        };
904        config::save_config(&cfg, &t.fs, &t.paths).unwrap();
905
906        let ctx = t.ctx();
907        let skill = sample_skill("1.0.0");
908        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
909
910        let platforms: Vec<_> = plan.targets.iter().map(|t| t.platform).collect();
911        assert!(platforms.contains(&Platform::Claude), "should include Claude");
912        assert!(platforms.contains(&Platform::Codex), "should include Codex");
913        assert!(plan.targets[0].cmx_managed, "cmx_managed should be true");
914    }
915
916    #[test]
917    fn no_config_but_non_empty_codex_lock_targets_codex() {
918        let t = TestContext::new();
919        let codex_paths = t.paths.with_platform(Platform::Codex);
920        crate::test_support::save_lock_with_entry(
921            &t.fs,
922            &codex_paths,
923            "other-skill",
924            crate::test_support::sample_lock_entry(),
925            InstallScope::Global,
926        );
927
928        let ctx = t.ctx();
929        let skill = sample_skill("1.0.0");
930        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
931
932        let platforms: Vec<_> = plan.targets.iter().map(|t| t.platform).collect();
933        assert!(
934            platforms.contains(&Platform::Codex),
935            "Codex lock non-empty → should be targeted"
936        );
937    }
938
939    // -----------------------------------------------------------------------
940    // Tests 7–12: version-guard actions
941    // -----------------------------------------------------------------------
942
943    fn plan_with_locked_version(
944        t: &TestContext,
945        locked_version: &str,
946        locked_checksum: &str,
947        bundled_version: &str,
948        force: bool,
949    ) -> InstallPlan {
950        // Set up a lock entry for Claude with the given version and checksum.
951        let claude_paths = t.paths.with_platform(Platform::Claude);
952        let skill_dir = claude_paths
953            .install_dir(ArtifactKind::Skill, InstallScope::Global)
954            .unwrap()
955            .join("sample");
956        t.fs.add_dir(skill_dir.clone());
957
958        crate::test_support::save_lock_with_entry(
959            &t.fs,
960            &claude_paths,
961            "sample",
962            LockEntry {
963                artifact_type: ArtifactKind::Skill,
964                version: Some(locked_version.to_string()),
965                installed_at: "2024-01-01T00:00:00Z".to_string(),
966                source: LockSource {
967                    repo: "bundled:sample".to_string(),
968                    path: "skills/sample".to_string(),
969                },
970                source_checksum: locked_checksum.to_string(),
971                installed_checksum: locked_checksum.to_string(),
972            },
973            InstallScope::Global,
974        );
975        let skill = sample_skill(bundled_version);
976        let ctx = t.ctx();
977        installer(bundled_version).plan(&skill, Scope::Global, force, &ctx).unwrap()
978    }
979
980    #[test]
981    fn older_lock_version_produces_update() {
982        let t = TestContext::new();
983        let plan = plan_with_locked_version(&t, "0.9.0", "sha256:old", "1.0.0", false);
984        assert!(matches!(plan.targets[0].action, TargetAction::Update { .. }));
985    }
986
987    #[test]
988    fn same_version_identical_checksum_on_disk_produces_skip() {
989        let t = TestContext::new();
990        let skill = sample_skill("1.0.0");
991        let checksum = skill_fs::checksum_bundled(&skill.files);
992
993        let claude_paths = t.paths.with_platform(Platform::Claude);
994        let skill_dir = claude_paths
995            .install_dir(ArtifactKind::Skill, InstallScope::Global)
996            .unwrap()
997            .join("sample");
998        // Put a file on disk so `exists()` returns true
999        t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Sample skill\n");
1000
1001        crate::test_support::save_lock_with_entry(
1002            &t.fs,
1003            &claude_paths,
1004            "sample",
1005            LockEntry {
1006                artifact_type: ArtifactKind::Skill,
1007                version: Some("1.0.0".to_string()),
1008                installed_at: "2024-01-01T00:00:00Z".to_string(),
1009                source: LockSource {
1010                    repo: "bundled:sample".to_string(),
1011                    path: "skills/sample".to_string(),
1012                },
1013                source_checksum: checksum.clone(),
1014                installed_checksum: checksum.clone(),
1015            },
1016            InstallScope::Global,
1017        );
1018
1019        let ctx = t.ctx();
1020        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1021        assert!(matches!(plan.targets[0].action, TargetAction::Skip));
1022    }
1023
1024    #[test]
1025    fn same_version_differing_content_no_force_produces_drifted_skip() {
1026        let t = TestContext::new();
1027        let skill = sample_skill("1.0.0");
1028
1029        let claude_paths = t.paths.with_platform(Platform::Claude);
1030        let skill_dir = claude_paths
1031            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1032            .unwrap()
1033            .join("sample");
1034        t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Modified\n");
1035
1036        crate::test_support::save_lock_with_entry(
1037            &t.fs,
1038            &claude_paths,
1039            "sample",
1040            LockEntry {
1041                artifact_type: ArtifactKind::Skill,
1042                version: Some("1.0.0".to_string()),
1043                installed_at: "2024-01-01T00:00:00Z".to_string(),
1044                source: LockSource {
1045                    repo: "bundled:sample".to_string(),
1046                    path: "skills/sample".to_string(),
1047                },
1048                source_checksum: "sha256:different".to_string(),
1049                installed_checksum: "sha256:different".to_string(),
1050            },
1051            InstallScope::Global,
1052        );
1053
1054        let ctx = t.ctx();
1055        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1056        assert!(matches!(plan.targets[0].action, TargetAction::DriftedSkip { .. }));
1057    }
1058
1059    #[test]
1060    fn newer_lock_no_force_produces_refuse_newer_and_is_blocked() {
1061        let t = TestContext::new();
1062        let plan = plan_with_locked_version(&t, "2.0.0", "sha256:new", "1.0.0", false);
1063        assert!(matches!(plan.targets[0].action, TargetAction::RefuseNewer { .. }));
1064        assert!(plan.is_blocked());
1065    }
1066
1067    #[test]
1068    fn newer_lock_with_force_produces_downgrade() {
1069        let t = TestContext::new();
1070        let plan = plan_with_locked_version(&t, "2.0.0", "sha256:new", "1.0.0", true);
1071        assert!(matches!(plan.targets[0].action, TargetAction::Downgrade { .. }));
1072        assert!(!plan.is_blocked());
1073    }
1074
1075    #[test]
1076    fn non_semver_versions_use_string_equality_fallback() {
1077        // Both non-semver and equal → Equal → Skip (if checksum matches)
1078        let t = TestContext::new();
1079        let skill = sample_skill("v1-alpha");
1080        let checksum = skill_fs::checksum_bundled(&skill.files);
1081
1082        let claude_paths = t.paths.with_platform(Platform::Claude);
1083        let skill_dir = claude_paths
1084            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1085            .unwrap()
1086            .join("sample");
1087        t.fs.add_dir(skill_dir);
1088
1089        crate::test_support::save_lock_with_entry(
1090            &t.fs,
1091            &claude_paths,
1092            "sample",
1093            LockEntry {
1094                artifact_type: ArtifactKind::Skill,
1095                version: Some("v1-alpha".to_string()),
1096                installed_at: "2024-01-01T00:00:00Z".to_string(),
1097                source: LockSource {
1098                    repo: "bundled:sample".to_string(),
1099                    path: "skills/sample".to_string(),
1100                },
1101                source_checksum: checksum.clone(),
1102                installed_checksum: checksum.clone(),
1103            },
1104            InstallScope::Global,
1105        );
1106
1107        // Non-semver, same version strings → Equal → install since not on disk
1108        let ctx = t.ctx();
1109        let plan = installer("v1-alpha").plan(&skill, Scope::Global, false, &ctx).unwrap();
1110        // The skill_dest doesn't have a file on disk (only dir exists via add_dir)
1111        // but the dir itself exists — the logic returns Skip since checksum matches
1112        // and exists returns true for a dir.
1113        // Actually FakeFilesystem.exists checks both files AND dirs.
1114        // The dir was added via add_dir so exists returns true.
1115        // Same version + matching checksum + exists → Skip
1116        assert!(
1117            matches!(plan.targets[0].action, TargetAction::Skip)
1118                || matches!(plan.targets[0].action, TargetAction::Install),
1119            "non-semver equal versions should not produce RefuseNewer or Downgrade"
1120        );
1121    }
1122
1123    #[test]
1124    fn missing_skill_md_returns_error() {
1125        let t = TestContext::new();
1126        let skill = BundledSkill::from_files(vec![make_file("scripts/tool.py", "code")]);
1127        let ctx = t.ctx();
1128        let result = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx);
1129        assert!(result.is_err());
1130        assert!(result.unwrap_err().to_string().contains("SKILL.md"));
1131    }
1132
1133    // -----------------------------------------------------------------------
1134    // Tests 14–21: apply()
1135    // -----------------------------------------------------------------------
1136
1137    #[test]
1138    fn apply_fresh_machine_writes_files_and_lock_source_not_registered() {
1139        let t = TestContext::new();
1140        let skill = sample_skill("1.0.0");
1141        let ctx = t.ctx();
1142        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1143        let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1144
1145        assert_eq!(report.applied().count(), 1);
1146        assert!(!report.source_registered, "no managed set → no source registration");
1147
1148        // Files should be on disk
1149        let skill_dir = t
1150            .paths
1151            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1152            .unwrap()
1153            .join("sample");
1154        assert!(t.fs.file_exists(&skill_dir.join("SKILL.md")));
1155
1156        // Lock entry should exist
1157        let lock = lockfile::load(InstallScope::Global, &t.fs, &t.paths).unwrap();
1158        assert!(lock.packages.contains_key("sample"));
1159    }
1160
1161    #[test]
1162    fn installed_checksum_equals_source_checksum() {
1163        let t = TestContext::new();
1164        let skill = sample_skill("1.0.0");
1165        let ctx = t.ctx();
1166        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1167        let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1168
1169        let first_applied = report.applied().next().unwrap();
1170        assert_eq!(first_applied.installed_checksum.as_deref().unwrap(), plan.source_checksum);
1171    }
1172
1173    #[test]
1174    fn cmx_managed_registers_source_and_materializes_dir() {
1175        let t = TestContext::new();
1176        let cfg = CmxConfig {
1177            platforms: vec![Platform::Claude],
1178            ..Default::default()
1179        };
1180        config::save_config(&cfg, &t.fs, &t.paths).unwrap();
1181
1182        let skill = sample_skill("1.0.0");
1183        let ctx = t.ctx();
1184        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1185        let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1186
1187        assert!(report.source_registered, "managed set → source should be registered");
1188
1189        let sources = config::load_sources(&t.fs, &t.paths).unwrap();
1190        assert!(sources.sources.contains_key("bundled:sample"), "source entry should exist");
1191    }
1192
1193    #[test]
1194    fn skip_and_drifted_skip_plan_writes_nothing() {
1195        let t = TestContext::new();
1196        let skill = sample_skill("1.0.0");
1197        let checksum = skill_fs::checksum_bundled(&skill.files);
1198
1199        let claude_paths = t.paths.with_platform(Platform::Claude);
1200        let skill_dir = claude_paths
1201            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1202            .unwrap()
1203            .join("sample");
1204        t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Sample skill\n");
1205
1206        crate::test_support::save_lock_with_entry(
1207            &t.fs,
1208            &claude_paths,
1209            "sample",
1210            LockEntry {
1211                artifact_type: ArtifactKind::Skill,
1212                version: Some("1.0.0".to_string()),
1213                installed_at: "2024-01-01T00:00:00Z".to_string(),
1214                source: LockSource {
1215                    repo: "bundled:sample".to_string(),
1216                    path: "skills/sample".to_string(),
1217                },
1218                source_checksum: checksum.clone(),
1219                installed_checksum: checksum.clone(),
1220            },
1221            InstallScope::Global,
1222        );
1223
1224        let ctx = t.ctx();
1225        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1226        // Plan should be Skip
1227        assert!(matches!(plan.targets[0].action, TargetAction::Skip));
1228        assert_eq!(plan.write_count(), 0);
1229    }
1230
1231    #[test]
1232    fn blocked_plan_returns_err_on_apply() {
1233        let t = TestContext::new();
1234        // Create a plan with a RefuseNewer by having a newer version installed
1235        let skill = sample_skill("1.0.0");
1236        let claude_paths = t.paths.with_platform(Platform::Claude);
1237        let skill_dir = claude_paths
1238            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1239            .unwrap()
1240            .join("sample");
1241        t.fs.add_dir(skill_dir);
1242
1243        crate::test_support::save_lock_with_entry(
1244            &t.fs,
1245            &claude_paths,
1246            "sample",
1247            LockEntry {
1248                artifact_type: ArtifactKind::Skill,
1249                version: Some("2.0.0".to_string()),
1250                installed_at: "2024-01-01T00:00:00Z".to_string(),
1251                source: LockSource {
1252                    repo: "bundled:sample".to_string(),
1253                    path: "skills/sample".to_string(),
1254                },
1255                source_checksum: "sha256:abc".to_string(),
1256                installed_checksum: "sha256:abc".to_string(),
1257            },
1258            InstallScope::Global,
1259        );
1260
1261        let ctx = t.ctx();
1262        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1263        assert!(plan.is_blocked());
1264
1265        let result = installer("1.0.0").apply(&skill, &plan, &ctx);
1266        assert!(result.is_err());
1267    }
1268
1269    #[test]
1270    fn parity_guard_rejects_mismatched_bundled_skill() {
1271        let t = TestContext::new();
1272        let skill_v1 = sample_skill("1.0.0");
1273        let ctx = t.ctx();
1274        let plan = installer("1.0.0").plan(&skill_v1, Scope::Global, false, &ctx).unwrap();
1275
1276        // Apply with a different skill
1277        let skill_v2 = sample_skill("2.0.0");
1278        let result = installer("1.0.0").apply(&skill_v2, &plan, &ctx);
1279        assert!(result.is_err());
1280        assert!(result.unwrap_err().to_string().contains("Parity"));
1281    }
1282
1283    #[test]
1284    fn shared_dir_managed_codex_pi_written_once_both_locks_updated() {
1285        let t = TestContext::new();
1286        // Configure both Codex and Pi as managed platforms
1287        let cfg = CmxConfig {
1288            platforms: vec![Platform::Codex, Platform::Pi],
1289            ..Default::default()
1290        };
1291        config::save_config(&cfg, &t.fs, &t.paths).unwrap();
1292
1293        let skill = sample_skill("1.0.0");
1294        let ctx = t.ctx();
1295        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1296        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1297
1298        // Both Codex and Pi resolve skills to .agents/skills — same path.
1299        let codex_paths = t.paths.with_platform(Platform::Codex);
1300        let pi_paths = t.paths.with_platform(Platform::Pi);
1301
1302        let codex_lock = lockfile::load(InstallScope::Global, &t.fs, &codex_paths).unwrap();
1303        let pi_lock = lockfile::load(InstallScope::Global, &t.fs, &pi_paths).unwrap();
1304
1305        assert!(codex_lock.packages.contains_key("sample"), "Codex lock should have entry");
1306        assert!(pi_lock.packages.contains_key("sample"), "Pi lock should have entry");
1307    }
1308
1309    #[test]
1310    fn on_disk_file_set_matches_planned_dest_paths() {
1311        let t = TestContext::new();
1312        let skill = sample_skill("1.0.0");
1313        let ctx = t.ctx();
1314        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1315        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1316
1317        for target in &plan.targets {
1318            for pf in &target.files {
1319                assert!(
1320                    t.fs.file_exists(&pf.dest_path),
1321                    "expected file on disk: {}",
1322                    pf.dest_path.display()
1323                );
1324            }
1325        }
1326    }
1327
1328    // -----------------------------------------------------------------------
1329    // Tests 22–24: status()
1330    // -----------------------------------------------------------------------
1331
1332    #[test]
1333    fn not_installed_on_fresh_machine() {
1334        let t = TestContext::new();
1335        let ctx = t.ctx();
1336        let status = installer("1.0.0").status(Scope::Global, &ctx).unwrap();
1337        assert!(!status.targets[0].installed);
1338        assert!(!status.targets[0].tracked);
1339    }
1340
1341    #[test]
1342    fn after_apply_installed_tracked_version_matches_not_drifted() {
1343        let t = TestContext::new();
1344        let skill = sample_skill("1.0.0");
1345        let ctx = t.ctx();
1346        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1347        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1348
1349        let status = installer("1.0.0").status(Scope::Global, &ctx).unwrap();
1350        assert!(status.targets[0].installed);
1351        assert!(status.targets[0].tracked);
1352        assert_eq!(status.targets[0].installed_version.as_deref(), Some("1.0.0"));
1353        assert!(!status.targets[0].drifted);
1354    }
1355
1356    #[test]
1357    fn mutate_skill_md_on_disk_produces_drifted() {
1358        let t = TestContext::new();
1359        let skill = sample_skill("1.0.0");
1360        let ctx = t.ctx();
1361        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1362        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1363
1364        // Mutate the on-disk SKILL.md
1365        let skill_dir = t
1366            .paths
1367            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1368            .unwrap()
1369            .join("sample");
1370        t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# MODIFIED\n");
1371
1372        let status = installer("1.0.0").status(Scope::Global, &ctx).unwrap();
1373        assert!(status.targets[0].drifted, "mutated SKILL.md should report drifted");
1374    }
1375
1376    // -----------------------------------------------------------------------
1377    // Tests 25–28: remove()
1378    // -----------------------------------------------------------------------
1379
1380    #[test]
1381    fn remove_deletes_dir_and_clears_lock() {
1382        let t = TestContext::new();
1383        let skill = sample_skill("1.0.0");
1384        let ctx = t.ctx();
1385        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1386        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1387
1388        let skill_dir = t
1389            .paths
1390            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1391            .unwrap()
1392            .join("sample");
1393        assert!(t.fs.exists(&skill_dir));
1394
1395        let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1396        assert!(report.was_on_disk);
1397        assert!(report.was_tracked);
1398        assert!(!t.fs.exists(&skill_dir));
1399
1400        let lock = lockfile::load(InstallScope::Global, &t.fs, &t.paths).unwrap();
1401        assert!(!lock.packages.contains_key("sample"));
1402    }
1403
1404    #[test]
1405    fn shared_dir_managed_codex_pi_removed_once_both_locks_cleared() {
1406        let t = TestContext::new();
1407        let cfg = CmxConfig {
1408            platforms: vec![Platform::Codex, Platform::Pi],
1409            ..Default::default()
1410        };
1411        config::save_config(&cfg, &t.fs, &t.paths).unwrap();
1412
1413        let skill = sample_skill("1.0.0");
1414        let ctx = t.ctx();
1415        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1416        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1417
1418        let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1419        assert!(report.was_on_disk);
1420        assert!(report.platforms_cleared.contains(&Platform::Codex));
1421        assert!(report.platforms_cleared.contains(&Platform::Pi));
1422
1423        // Both lock entries should be gone
1424        let codex_paths = t.paths.with_platform(Platform::Codex);
1425        let pi_paths = t.paths.with_platform(Platform::Pi);
1426        let codex_lock = lockfile::load(InstallScope::Global, &t.fs, &codex_paths).unwrap();
1427        let pi_lock = lockfile::load(InstallScope::Global, &t.fs, &pi_paths).unwrap();
1428        assert!(!codex_lock.packages.contains_key("sample"));
1429        assert!(!pi_lock.packages.contains_key("sample"));
1430    }
1431
1432    #[test]
1433    fn cmx_managed_remove_clears_source_and_materialized_dir() {
1434        let t = TestContext::new();
1435        let cfg = CmxConfig {
1436            platforms: vec![Platform::Claude],
1437            ..Default::default()
1438        };
1439        config::save_config(&cfg, &t.fs, &t.paths).unwrap();
1440
1441        let skill = sample_skill("1.0.0");
1442        let ctx = t.ctx();
1443        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1444        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1445
1446        // Confirm source was registered
1447        let sources = config::load_sources(&t.fs, &t.paths).unwrap();
1448        assert!(sources.sources.contains_key("bundled:sample"));
1449
1450        let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1451        assert!(report.source_unregistered);
1452
1453        let sources_after = config::load_sources(&t.fs, &t.paths).unwrap();
1454        assert!(!sources_after.sources.contains_key("bundled:sample"));
1455    }
1456
1457    #[test]
1458    fn remove_when_nothing_installed_returns_ok_all_false() {
1459        let t = TestContext::new();
1460        let ctx = t.ctx();
1461        let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1462        assert!(!report.was_on_disk);
1463        assert!(!report.was_tracked);
1464        assert!(!report.source_unregistered);
1465    }
1466
1467    // -----------------------------------------------------------------------
1468    // New constructor and derive tests
1469    // -----------------------------------------------------------------------
1470
1471    #[test]
1472    fn single_md_builds_single_skill_md() {
1473        let skill = BundledSkill::single_md("---\nversion: 1.0.0\n---\n# My skill\n");
1474        assert_eq!(skill.files.len(), 1);
1475        assert!(skill.has_skill_md());
1476        assert_eq!(skill.files[0].rel_path, std::path::PathBuf::from("SKILL.md"));
1477    }
1478
1479    #[test]
1480    fn tool_identity_new_sets_fields() {
1481        let id = ToolIdentity::new("mytool", "1.2.3");
1482        assert_eq!(id.name, "mytool");
1483        assert_eq!(id.version, "1.2.3");
1484    }
1485
1486    #[test]
1487    fn scope_partial_eq() {
1488        assert_eq!(Scope::Global, Scope::Global);
1489        assert_eq!(Scope::Local, Scope::Local);
1490        assert_ne!(Scope::Global, Scope::Local);
1491    }
1492
1493    // -----------------------------------------------------------------------
1494    // Report fidelity tests
1495    // -----------------------------------------------------------------------
1496
1497    #[test]
1498    fn skipped_target_outcome_carries_dest_dir() {
1499        let t = TestContext::new();
1500        let skill = sample_skill("1.0.0");
1501        let checksum = skill_fs::checksum_bundled(&skill.files);
1502
1503        let claude_paths = t.paths.with_platform(Platform::Claude);
1504        let skill_dir = claude_paths
1505            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1506            .unwrap()
1507            .join("sample");
1508        t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Sample skill\n");
1509
1510        crate::test_support::save_lock_with_entry(
1511            &t.fs,
1512            &claude_paths,
1513            "sample",
1514            LockEntry {
1515                artifact_type: ArtifactKind::Skill,
1516                version: Some("1.0.0".to_string()),
1517                installed_at: "2024-01-01T00:00:00Z".to_string(),
1518                source: LockSource {
1519                    repo: "bundled:sample".to_string(),
1520                    path: "skills/sample".to_string(),
1521                },
1522                source_checksum: checksum.clone(),
1523                installed_checksum: checksum.clone(),
1524            },
1525            InstallScope::Global,
1526        );
1527
1528        let ctx = t.ctx();
1529        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1530        let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1531
1532        // The skip target must preserve dest_dir
1533        let skip = report.skipped().next().expect("expected a skipped target");
1534        assert!(
1535            !skip.dest_dir.as_os_str().is_empty(),
1536            "dest_dir must be non-empty on skipped target"
1537        );
1538        assert!(matches!(skip.action, TargetAction::Skip));
1539        assert_eq!(skip.installed_checksum, None);
1540    }
1541
1542    #[test]
1543    fn drifted_skip_outcome_is_distinguishable_from_plain_skip() {
1544        let t = TestContext::new();
1545        let skill = sample_skill("1.0.0");
1546
1547        let claude_paths = t.paths.with_platform(Platform::Claude);
1548        let skill_dir = claude_paths
1549            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1550            .unwrap()
1551            .join("sample");
1552        t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Modified\n");
1553
1554        crate::test_support::save_lock_with_entry(
1555            &t.fs,
1556            &claude_paths,
1557            "sample",
1558            LockEntry {
1559                artifact_type: ArtifactKind::Skill,
1560                version: Some("1.0.0".to_string()),
1561                installed_at: "2024-01-01T00:00:00Z".to_string(),
1562                source: LockSource {
1563                    repo: "bundled:sample".to_string(),
1564                    path: "skills/sample".to_string(),
1565                },
1566                source_checksum: "sha256:different".to_string(),
1567                installed_checksum: "sha256:different".to_string(),
1568            },
1569            InstallScope::Global,
1570        );
1571
1572        let ctx = t.ctx();
1573        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1574        let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1575
1576        let skip = report.skipped().next().expect("expected a skipped target");
1577        assert!(
1578            matches!(skip.action, TargetAction::DriftedSkip { .. }),
1579            "action must be DriftedSkip, not plain Skip"
1580        );
1581    }
1582
1583    // -----------------------------------------------------------------------
1584    // Display tests
1585    // -----------------------------------------------------------------------
1586
1587    #[test]
1588    fn install_plan_display_contains_target_lines() {
1589        let t = TestContext::new();
1590        let skill = sample_skill("1.0.0");
1591        let ctx = t.ctx();
1592        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1593        let rendered = plan.to_string();
1594        assert!(rendered.contains("sample"), "plan display must include tool name");
1595        assert!(rendered.contains("1.0.0"), "plan display must include version");
1596        assert!(rendered.contains("install"), "plan display must include action");
1597    }
1598
1599    #[test]
1600    fn report_display_distinguishes_drifted_skip() {
1601        let t = TestContext::new();
1602        let skill = sample_skill("1.0.0");
1603        let checksum = skill_fs::checksum_bundled(&skill.files);
1604
1605        // First apply: fresh install
1606        let ctx = t.ctx();
1607        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1608        let up_to_date_report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1609        let up_to_date_text = up_to_date_report.to_string();
1610
1611        // Second apply (same version, same checksum) → Skip
1612        let plan2 = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1613        let skip_report = installer("1.0.0").apply(&skill, &plan2, &ctx).unwrap();
1614        let skip_text = skip_report.to_string();
1615        assert!(skip_text.contains("up to date"), "up-to-date skip must say 'up to date'");
1616
1617        // Set up drifted scenario
1618        let t2 = TestContext::new();
1619        let claude_paths = t2.paths.with_platform(Platform::Claude);
1620        let skill_dir = claude_paths
1621            .install_dir(ArtifactKind::Skill, InstallScope::Global)
1622            .unwrap()
1623            .join("sample");
1624        t2.fs
1625            .add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Modified\n");
1626        crate::test_support::save_lock_with_entry(
1627            &t2.fs,
1628            &claude_paths,
1629            "sample",
1630            LockEntry {
1631                artifact_type: ArtifactKind::Skill,
1632                version: Some("1.0.0".to_string()),
1633                installed_at: "2024-01-01T00:00:00Z".to_string(),
1634                source: LockSource {
1635                    repo: "bundled:sample".to_string(),
1636                    path: "skills/sample".to_string(),
1637                },
1638                source_checksum: "sha256:different".to_string(),
1639                installed_checksum: "sha256:different".to_string(),
1640            },
1641            InstallScope::Global,
1642        );
1643        let ctx2 = t2.ctx();
1644        let drifted_plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx2).unwrap();
1645        let drifted_report = installer("1.0.0").apply(&skill, &drifted_plan, &ctx2).unwrap();
1646        let drifted_text = drifted_report.to_string();
1647
1648        // Drifted display must differ from up-to-date skip display
1649        assert!(
1650            drifted_text.contains("drifted"),
1651            "drifted skip must mention 'drifted' in output, got: {drifted_text}"
1652        );
1653        assert_ne!(
1654            skip_text, drifted_text,
1655            "up-to-date skip and drifted skip must produce different display output"
1656        );
1657        // suppress unused warning
1658        let _ = checksum;
1659        let _ = up_to_date_text;
1660    }
1661
1662    #[test]
1663    fn remove_report_display_notes_lockfile_left() {
1664        let t = TestContext::new();
1665        let skill = sample_skill("1.0.0");
1666        let ctx = t.ctx();
1667        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1668        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1669
1670        let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1671        let rendered = report.to_string();
1672        assert!(
1673            rendered.contains("cmx-lock.json"),
1674            "remove report must note the lockfile is left on disk"
1675        );
1676    }
1677}