1use 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#[derive(Debug, Clone)]
46pub struct ToolIdentity {
47 pub name: String,
48 pub version: String,
49}
50
51impl ToolIdentity {
52 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
61pub struct BundledSkill {
63 pub files: Vec<SkillFile>,
64}
65
66impl BundledSkill {
67 pub fn from_files(files: Vec<SkillFile>) -> Self {
70 Self { files }
71 }
72
73 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 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#[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#[non_exhaustive]
125#[derive(Debug, Clone)]
126pub enum TargetAction {
127 Install,
129 Update {
131 from: Option<String>,
133 },
134 Skip,
136 DriftedSkip { installed: String },
139 RefuseNewer { installed: String },
142 Downgrade { from: String },
144}
145
146impl TargetAction {
147 pub fn will_write(&self) -> bool {
149 matches!(self, Self::Install | Self::Update { .. } | Self::Downgrade { .. })
150 }
151
152 pub fn is_blocked(&self) -> bool {
154 matches!(self, Self::RefuseNewer { .. })
155 }
156}
157
158#[derive(Debug, Clone)]
160pub struct PlannedFile {
161 pub rel_path: PathBuf,
163 pub dest_path: PathBuf,
165}
166
167#[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 pub cmx_managed: bool,
177}
178
179#[derive(Debug)]
181pub struct InstallPlan {
182 pub tool: ToolIdentity,
183 pub scope: InstallScope,
184 pub source_checksum: String,
185 pub cmx_present: bool,
187 pub force: bool,
188 pub targets: Vec<TargetPlan>,
189}
190
191impl InstallPlan {
192 pub fn is_blocked(&self) -> bool {
194 self.targets.iter().any(|t| t.action.is_blocked())
195 }
196
197 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#[derive(Debug)]
248pub struct TargetOutcome {
249 pub platform: Platform,
250 pub dest_dir: PathBuf,
251 pub action: TargetAction,
252 pub files_written: usize,
254 pub installed_checksum: Option<String>,
257}
258
259#[derive(Debug)]
266pub struct Report {
267 pub tool: ToolIdentity,
268 pub scope: InstallScope,
269 pub targets: Vec<TargetOutcome>,
271 pub source_registered: bool,
273}
274
275impl Report {
276 pub fn applied(&self) -> impl Iterator<Item = &TargetOutcome> {
278 self.targets.iter().filter(|o| o.action.will_write())
279 }
280
281 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#[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#[derive(Debug)]
345pub struct Status {
346 pub tool_name: String,
347 pub scope: InstallScope,
348 pub targets: Vec<TargetStatus>,
349}
350
351#[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
367pub struct SkillInstaller {
373 tool: ToolIdentity,
374}
375
376impl SkillInstaller {
377 pub fn new(tool: ToolIdentity) -> Self {
379 Self { tool }
380 }
381
382 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
706fn 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
731fn 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 TargetAction::Update {
747 from: installed_version.map(str::to_string),
748 }
749 }
750 Ordering::Equal => {
751 if entry.installed_checksum == source_checksum {
753 if ctx.fs.exists(skill_dest) {
755 TargetAction::Skip
756 } else {
757 TargetAction::Install
759 }
760 } else {
761 if ctx.fs.exists(skill_dest) {
763 TargetAction::DriftedSkip {
765 installed: installed_version.unwrap_or("unknown").to_string(),
766 }
767 } else {
768 TargetAction::Install
769 }
770 }
771 }
772 Ordering::Greater => {
773 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#[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 #[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 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 #[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 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 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 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 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 let ctx = t.ctx();
1109 let plan = installer("v1-alpha").plan(&skill, Scope::Global, false, &ctx).unwrap();
1110 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 #[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 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 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 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 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 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 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 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 #[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 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 #[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 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 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 #[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 #[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 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 #[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 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 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 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 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 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}