1use anyhow::{Result, bail};
28use std::cmp::Ordering;
29use std::collections::{BTreeMap, BTreeSet};
30use std::path::PathBuf;
31
32use crate::checksum;
33use crate::config;
34use crate::context::AppContext;
35use crate::frontmatter;
36use crate::fs_util;
37use crate::lockfile;
38use crate::platform::Platform;
39use crate::platform_iter;
40use crate::skill_fs::{self, SkillFile};
41use crate::targets;
42use crate::types::{ArtifactKind, InstallScope, LockEntry, LockSource, SourceEntry, SourceType};
43
44#[derive(Debug, Clone)]
50pub struct ToolIdentity {
51 pub name: String,
52 pub version: String,
53}
54
55impl ToolIdentity {
56 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
58 Self {
59 name: name.into(),
60 version: version.into(),
61 }
62 }
63}
64
65pub struct BundledSkill {
67 pub files: Vec<SkillFile>,
68}
69
70impl BundledSkill {
71 pub fn from_files(files: Vec<SkillFile>) -> Self {
74 Self { files }
75 }
76
77 pub fn single_md(content: &str) -> Self {
82 Self {
83 files: vec![SkillFile {
84 rel_path: PathBuf::from("SKILL.md"),
85 bytes: content.as_bytes().to_vec(),
86 }],
87 }
88 }
89
90 pub fn has_skill_md(&self) -> bool {
93 self.files.iter().any(|f| f.rel_path.as_os_str() == "SKILL.md")
94 }
95}
96
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
99pub enum Scope {
100 #[default]
101 Global,
102 Local,
103}
104
105impl Scope {
106 pub fn to_install_scope(self) -> InstallScope {
107 match self {
108 Scope::Global => InstallScope::Global,
109 Scope::Local => InstallScope::Local,
110 }
111 }
112}
113
114#[non_exhaustive]
129#[derive(Debug, Clone)]
130pub enum TargetAction {
131 Install,
133 Update {
135 from: Option<String>,
137 },
138 Skip,
140 DriftedSkip { installed: String },
143 RefuseNewer { installed: String },
146 Downgrade { from: String },
148}
149
150impl TargetAction {
151 pub fn will_write(&self) -> bool {
153 matches!(self, Self::Install | Self::Update { .. } | Self::Downgrade { .. })
154 }
155
156 pub fn is_blocked(&self) -> bool {
158 matches!(self, Self::RefuseNewer { .. })
159 }
160}
161
162#[derive(Debug, Clone)]
164pub struct PlannedFile {
165 pub rel_path: PathBuf,
167 pub dest_path: PathBuf,
169}
170
171#[derive(Debug)]
173pub struct TargetPlan {
174 pub platform: Platform,
175 pub scope: InstallScope,
176 pub dest_dir: PathBuf,
177 pub files: Vec<PlannedFile>,
178 pub action: TargetAction,
179 pub cmx_managed: bool,
181}
182
183#[derive(Debug)]
185pub struct InstallPlan {
186 pub tool: ToolIdentity,
187 pub scope: InstallScope,
188 pub source_checksum: String,
189 pub cmx_present: bool,
191 pub force: bool,
192 pub targets: Vec<TargetPlan>,
193}
194
195impl InstallPlan {
196 pub fn is_blocked(&self) -> bool {
198 self.targets.iter().any(|t| t.action.is_blocked())
199 }
200
201 pub fn write_count(&self) -> usize {
203 self.targets.iter().filter(|t| t.action.will_write()).count()
204 }
205}
206
207impl std::fmt::Display for InstallPlan {
208 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209 writeln!(f, "Install plan for {} v{}", self.tool.name, self.tool.version)?;
210 writeln!(f, " scope: {}", self.scope.label())?;
211 writeln!(f, " checksum: {}", self.source_checksum)?;
212 for target in &self.targets {
213 writeln!(
214 f,
215 " {} → {} ({})",
216 target.platform,
217 target.dest_dir.display(),
218 format_action(&target.action)
219 )?;
220 }
221 Ok(())
222 }
223}
224
225fn format_action(action: &TargetAction) -> String {
226 match action {
227 TargetAction::Install => "install".to_string(),
228 TargetAction::Update { from } => {
229 format!("update from {}", from.as_deref().unwrap_or("?"))
230 }
231 TargetAction::Skip => "skip (up to date)".to_string(),
232 TargetAction::DriftedSkip { installed } => {
233 format!("skip (drifted from {installed})")
234 }
235 TargetAction::RefuseNewer { installed } => {
236 format!("refuse (installed {installed} is newer)")
237 }
238 TargetAction::Downgrade { from } => format!("downgrade from {from}"),
239 }
240}
241
242#[derive(Debug)]
252pub struct TargetOutcome {
253 pub platform: Platform,
254 pub dest_dir: PathBuf,
255 pub action: TargetAction,
256 pub files_written: usize,
258 pub installed_checksum: Option<String>,
261 pub discarded_paths: Vec<PathBuf>,
263}
264
265#[derive(Debug)]
272pub struct Report {
273 pub tool: ToolIdentity,
274 pub scope: InstallScope,
275 pub targets: Vec<TargetOutcome>,
277 pub source_registered: bool,
279}
280
281impl Report {
282 pub fn applied(&self) -> impl Iterator<Item = &TargetOutcome> {
284 self.targets.iter().filter(|o| o.action.will_write())
285 }
286
287 pub fn skipped(&self) -> impl Iterator<Item = &TargetOutcome> {
289 self.targets.iter().filter(|o| !o.action.will_write())
290 }
291}
292
293struct PreparedWrites {
294 dirs_to_write: BTreeSet<PathBuf>,
295 discarded_paths_by_dir: BTreeMap<PathBuf, Vec<PathBuf>>,
296}
297
298impl std::fmt::Display for Report {
299 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300 writeln!(
301 f,
302 "Installed {} v{} ({})",
303 self.tool.name,
304 self.tool.version,
305 self.scope.label()
306 )?;
307 for outcome in &self.targets {
308 writeln!(
309 f,
310 " {} → {} ({})",
311 outcome.platform,
312 outcome.dest_dir.display(),
313 format_action(&outcome.action)
314 )?;
315 }
316 if self.source_registered {
317 writeln!(f, " (registered as cmx source)")?;
318 }
319 Ok(())
320 }
321}
322
323impl std::fmt::Display for RemoveReport {
324 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
325 writeln!(f, "Removed {} ({})", self.tool_name, self.scope.label())?;
326 for platform in &self.platforms_cleared {
327 writeln!(f, " {platform} lock entry cleared")?;
328 }
329 for dir in &self.removed_dirs {
330 writeln!(f, " removed: {}", dir.display())?;
331 }
332 if self.source_unregistered {
333 writeln!(f, " unregistered from cmx sources")?;
334 }
335 writeln!(f, " note: cmx-lock.json left on disk (shared with other tools)")?;
336 Ok(())
337 }
338}
339
340#[derive(Debug)]
346pub struct TargetStatus {
347 pub platform: Platform,
348 pub installed: bool,
349 pub installed_version: Option<String>,
350 pub drifted: bool,
351 pub tracked: bool,
352}
353
354#[derive(Debug)]
356pub struct Status {
357 pub tool_name: String,
358 pub scope: InstallScope,
359 pub targets: Vec<TargetStatus>,
360}
361
362#[derive(Debug)]
368pub struct RemoveReport {
369 pub tool_name: String,
370 pub scope: InstallScope,
371 pub removed_dirs: Vec<PathBuf>,
372 pub platforms_cleared: Vec<Platform>,
373 pub source_unregistered: bool,
374 pub was_on_disk: bool,
375 pub was_tracked: bool,
376}
377
378pub struct SkillInstaller {
384 tool: ToolIdentity,
385}
386
387impl SkillInstaller {
388 pub fn new(tool: ToolIdentity) -> Self {
390 Self { tool }
391 }
392
393 pub fn plan(
397 &self,
398 skill: &BundledSkill,
399 scope: Scope,
400 force: bool,
401 ctx: &AppContext<'_>,
402 ) -> Result<InstallPlan> {
403 if !skill.has_skill_md() {
404 bail!("BundledSkill for '{}' is missing SKILL.md", self.tool.name);
405 }
406
407 let files = frontmatter::reconcile_skill_version(&skill.files, &self.tool.version);
411 let source_checksum = skill_fs::checksum_bundled(&files);
412 let install_scope = scope.to_install_scope();
413
414 let platform_targets =
415 targets::resolve_targets(None, ArtifactKind::Skill, install_scope, ctx)?;
416
417 let cmx_managed = config::managed_platforms(ctx.fs, ctx.paths)?.is_some();
418 let cmx_present = cmx_managed || {
419 platform_iter::views_for(ctx.paths, platform_iter::all(), ArtifactKind::Skill).any(
421 |view| {
422 lockfile::load(install_scope, ctx.fs, &view.paths)
423 .ok()
424 .is_some_and(|l| !l.packages.is_empty())
425 },
426 )
427 };
428
429 let mut target_plans = Vec::new();
430 for &platform in &platform_targets {
436 let pv = ctx.paths.with_platform(platform);
437 let dest_dir = pv.require_install_dir(ArtifactKind::Skill, install_scope)?;
438 let skill_dest = dest_dir.join(&self.tool.name);
439
440 let planned_files: Vec<PlannedFile> = files
442 .iter()
443 .map(|f| PlannedFile {
444 rel_path: f.rel_path.clone(),
445 dest_path: skill_dest.join(&f.rel_path),
446 })
447 .collect();
448
449 let lock = lockfile::load(install_scope, ctx.fs, &pv)?;
451 let action = if let Some(entry) = lock.packages.get(&self.tool.name) {
452 decide_action_for_entry(
453 entry,
454 &self.tool.version,
455 &source_checksum,
456 force,
457 &skill_dest,
458 ctx,
459 )?
460 } else if ctx.fs.exists(&skill_dest) {
461 TargetAction::Install
463 } else {
464 TargetAction::Install
465 };
466
467 target_plans.push(TargetPlan {
468 platform,
469 scope: install_scope,
470 dest_dir: skill_dest,
471 files: planned_files,
472 action,
473 cmx_managed,
474 });
475 }
476
477 Ok(InstallPlan {
478 tool: self.tool.clone(),
479 scope: install_scope,
480 source_checksum,
481 cmx_present,
482 force,
483 targets: target_plans,
484 })
485 }
486
487 pub fn apply(
494 &self,
495 skill: &BundledSkill,
496 plan: &InstallPlan,
497 ctx: &AppContext<'_>,
498 ) -> Result<Report> {
499 if plan.is_blocked() {
500 bail!(
501 "Install plan for '{}' is blocked. Run with force=true to override.",
502 self.tool.name
503 );
504 }
505
506 let files = frontmatter::reconcile_skill_version(&skill.files, &self.tool.version);
509
510 let current_checksum = skill_fs::checksum_bundled(&files);
512 if current_checksum != plan.source_checksum {
513 bail!(
514 "Parity check failed for '{}': the BundledSkill has changed since plan() was called.",
515 self.tool.name
516 );
517 }
518
519 let PreparedWrites {
520 dirs_to_write,
521 discarded_paths_by_dir,
522 } = prepare_writes(plan, &files, ctx)?;
523
524 for dir in &dirs_to_write {
526 skill_fs::write_skill_files(dir, &files, ctx.fs)?;
527 }
528
529 let installed_checksum = plan.source_checksum.clone();
530 let installed_at = ctx.clock.now().to_rfc3339();
531
532 let mut targets: Vec<TargetOutcome> = Vec::new();
533
534 for target in &plan.targets {
535 if !target.action.will_write() {
536 targets.push(TargetOutcome {
537 platform: target.platform,
538 dest_dir: target.dest_dir.clone(),
539 action: target.action.clone(),
540 files_written: 0,
541 installed_checksum: None,
542 discarded_paths: Vec::new(),
543 });
544 continue;
545 }
546
547 let pv = ctx.paths.with_platform(target.platform);
549 lockfile::mutate(target.scope, ctx.fs, &pv, |lock| {
550 lock.packages.insert(
551 self.tool.name.clone(),
552 build_lock_entry(&self.tool, &installed_checksum, &installed_at),
553 );
554 })?;
555
556 targets.push(TargetOutcome {
557 platform: target.platform,
558 dest_dir: target.dest_dir.clone(),
559 action: target.action.clone(),
560 files_written: target.files.len(),
561 installed_checksum: Some(installed_checksum.clone()),
562 discarded_paths: discarded_paths_by_dir
563 .get(&target.dest_dir)
564 .cloned()
565 .unwrap_or_default(),
566 });
567 }
568
569 let source_registered = if plan.cmx_present
571 && config::managed_platforms(ctx.fs, ctx.paths)?.is_some()
572 {
573 let source_name = format!("bundled:{}", self.tool.name);
574 let home =
576 config::resolve_artifact_home(&config::load_config(ctx.fs, ctx.paths)?, ctx.paths);
577 let materialized = home.join("skills").join(&self.tool.name);
578 skill_fs::write_skill_files(&materialized, &files, ctx.fs)?;
579
580 config::mutate_sources(ctx.fs, ctx.paths, |sources| {
581 sources.sources.entry(source_name.clone()).or_insert_with(|| SourceEntry {
582 source_type: SourceType::Local,
583 path: Some(materialized.clone()),
584 url: None,
585 local_clone: None,
586 branch: None,
587 last_updated: Some(ctx.clock.now().to_rfc3339()),
588 });
589 Ok(())
590 })?;
591 true
592 } else {
593 false
594 };
595
596 Ok(Report {
597 tool: self.tool.clone(),
598 scope: plan.scope,
599 targets,
600 source_registered,
601 })
602 }
603
604 pub fn status(&self, scope: Scope, ctx: &AppContext<'_>) -> Result<Status> {
606 let install_scope = scope.to_install_scope();
607 let platform_targets =
608 targets::resolve_targets(None, ArtifactKind::Skill, install_scope, ctx)?;
609
610 let mut target_statuses = Vec::new();
611 for &platform in &platform_targets {
612 let pv = ctx.paths.with_platform(platform);
613 let skill_dir = pv
614 .install_dir(ArtifactKind::Skill, install_scope)
615 .map(|d| d.join(&self.tool.name));
616
617 let installed = skill_dir.as_ref().is_some_and(|d| ctx.fs.exists(d));
618
619 let lock = lockfile::load(install_scope, ctx.fs, &pv)?;
620 let lock_entry = lock.packages.get(&self.tool.name);
621 let tracked = lock_entry.is_some();
622 let installed_version = lock_entry.and_then(|e| e.version.clone());
623
624 let drifted = if installed && tracked {
625 if let (Some(dir), Some(entry)) = (&skill_dir, lock_entry) {
626 checksum::is_locally_modified(dir, ArtifactKind::Skill, entry, ctx.fs)
627 .unwrap_or(false)
628 } else {
629 false
630 }
631 } else {
632 false
633 };
634
635 target_statuses.push(TargetStatus {
636 platform,
637 installed,
638 installed_version,
639 drifted,
640 tracked,
641 });
642 }
643
644 Ok(Status {
645 tool_name: self.tool.name.clone(),
646 scope: install_scope,
647 targets: target_statuses,
648 })
649 }
650
651 pub fn remove(&self, scope: Scope, ctx: &AppContext<'_>) -> Result<RemoveReport> {
653 let install_scope = scope.to_install_scope();
654 let platform_targets = config::managed_or_all_platforms(ctx.fs, ctx.paths)?
655 .into_iter()
656 .filter(|p| p.supports(ArtifactKind::Skill))
657 .collect::<Vec<_>>();
658
659 let mut dirs_to_delete: BTreeSet<PathBuf> = BTreeSet::new();
660 let mut platforms_cleared: Vec<Platform> = Vec::new();
661 let mut was_tracked = false;
662
663 for &platform in &platform_targets {
664 let pv = ctx.paths.with_platform(platform);
665
666 if let Some(dir) = pv.install_dir(ArtifactKind::Skill, install_scope) {
668 let skill_dir = dir.join(&self.tool.name);
669 if ctx.fs.exists(&skill_dir) {
670 dirs_to_delete.insert(skill_dir);
671 }
672 }
673
674 let lock = lockfile::load(install_scope, ctx.fs, &pv)?;
676 if lock.packages.contains_key(&self.tool.name) {
677 lockfile::mutate(install_scope, ctx.fs, &pv, |l| {
678 l.packages.remove(&self.tool.name);
679 })?;
680 platforms_cleared.push(platform);
681 was_tracked = true;
682 }
683 }
684
685 let was_on_disk = !dirs_to_delete.is_empty();
686 let removed_dirs: Vec<PathBuf> = dirs_to_delete.into_iter().collect();
687 for dir in &removed_dirs {
688 ctx.fs.remove_dir_all(dir)?;
689 }
690
691 let source_name = format!("bundled:{}", self.tool.name);
693 let source_unregistered = if let Ok(sources) = config::load_sources(ctx.fs, ctx.paths) {
694 if sources.sources.contains_key(&source_name) {
695 if let Some(entry) = sources.sources.get(&source_name)
697 && let Some(path) = &entry.path
698 && ctx.fs.exists(path)
699 {
700 ctx.fs.remove_dir_all(path)?;
701 }
702 config::mutate_sources(ctx.fs, ctx.paths, |s| {
703 s.sources.remove(&source_name);
704 Ok(())
705 })?;
706 true
707 } else {
708 false
709 }
710 } else {
711 false
712 };
713
714 Ok(RemoveReport {
715 tool_name: self.tool.name.clone(),
716 scope: install_scope,
717 removed_dirs,
718 platforms_cleared,
719 source_unregistered,
720 was_on_disk,
721 was_tracked,
722 })
723 }
724}
725
726fn compare_versions(installed: Option<&str>, bundled: &str) -> Ordering {
736 let Some(inst) = installed else {
737 return Ordering::Less;
738 };
739 match (semver::Version::parse(inst), semver::Version::parse(bundled)) {
740 (Ok(a), Ok(b)) => a.cmp(&b),
741 _ => {
742 if inst == bundled {
743 Ordering::Equal
744 } else {
745 Ordering::Less
746 }
747 }
748 }
749}
750
751fn decide_action_for_entry(
753 entry: &LockEntry,
754 bundled_version: &str,
755 source_checksum: &str,
756 force: bool,
757 skill_dest: &std::path::Path,
758 ctx: &AppContext<'_>,
759) -> Result<TargetAction> {
760 let installed_version = entry.version.as_deref();
761 let cmp = compare_versions(installed_version, bundled_version);
762
763 match cmp {
764 Ordering::Less => Ok(TargetAction::Update {
765 from: installed_version.map(str::to_string),
766 }),
767 Ordering::Equal => {
768 if !ctx.fs.exists(skill_dest) {
769 return Ok(TargetAction::Install);
770 }
771
772 let disk_checksum =
773 checksum::checksum_artifact(skill_dest, ArtifactKind::Skill, ctx.fs)?;
774 if disk_checksum == source_checksum {
775 Ok(TargetAction::Skip)
776 } else if force {
777 Ok(TargetAction::Update {
778 from: installed_version.map(str::to_string),
779 })
780 } else {
781 Ok(TargetAction::DriftedSkip {
782 installed: installed_version.unwrap_or("unknown").to_string(),
783 })
784 }
785 }
786 Ordering::Greater => {
787 if force {
788 Ok(TargetAction::Downgrade {
789 from: installed_version.unwrap_or("unknown").to_string(),
790 })
791 } else {
792 Ok(TargetAction::RefuseNewer {
793 installed: installed_version.unwrap_or("unknown").to_string(),
794 })
795 }
796 }
797 }
798}
799
800fn discarded_paths_against_bundle(
801 skill_dest: &std::path::Path,
802 bundled_files: &[SkillFile],
803 ctx: &AppContext<'_>,
804) -> Result<Vec<PathBuf>> {
805 if !ctx.fs.exists(skill_dest) {
806 return Ok(Vec::new());
807 }
808
809 let installed_files = fs_util::collect_files_recursive(skill_dest, ctx.fs)?;
810 let mut installed_by_rel = BTreeMap::new();
811 for path in installed_files {
812 let rel = path.strip_prefix(skill_dest).unwrap_or(&path).to_path_buf();
813 installed_by_rel.insert(rel, ctx.fs.read(&path)?);
814 }
815
816 let mut bundled_by_rel = BTreeMap::new();
817 for file in skill_fs::canonical_files(bundled_files) {
818 bundled_by_rel.insert(file.rel_path.clone(), file.bytes.clone());
819 }
820
821 let mut changed_paths = Vec::new();
822 let relative_paths: BTreeSet<_> =
823 installed_by_rel.keys().chain(bundled_by_rel.keys()).cloned().collect();
824
825 for rel_path in relative_paths {
826 match (installed_by_rel.get(&rel_path), bundled_by_rel.get(&rel_path)) {
827 (Some(installed), Some(bundled)) if installed == bundled => {}
828 (Some(_) | None, Some(_)) | (Some(_), None) => {
829 changed_paths.push(skill_dest.join(rel_path));
830 }
831 (None, None) => {}
832 }
833 }
834
835 Ok(changed_paths)
836}
837
838fn prepare_writes(
839 plan: &InstallPlan,
840 files: &[SkillFile],
841 ctx: &AppContext<'_>,
842) -> Result<PreparedWrites> {
843 let mut dirs_to_write = BTreeSet::new();
844 let mut dirs_to_replace = BTreeSet::new();
845
846 for target in &plan.targets {
847 if target.action.will_write() {
848 dirs_to_write.insert(target.dest_dir.clone());
849 }
850 if plan.force
851 && matches!(target.action, TargetAction::Update { .. } | TargetAction::Downgrade { .. })
852 {
853 dirs_to_replace.insert(target.dest_dir.clone());
854 }
855 }
856
857 let mut discarded_paths_by_dir = BTreeMap::new();
858 for dir in &dirs_to_replace {
859 discarded_paths_by_dir
860 .insert(dir.clone(), discarded_paths_against_bundle(dir, files, ctx)?);
861 if ctx.fs.exists(dir) {
862 ctx.fs.remove_dir_all(dir)?;
863 }
864 }
865
866 Ok(PreparedWrites {
867 dirs_to_write,
868 discarded_paths_by_dir,
869 })
870}
871
872fn build_lock_entry(tool: &ToolIdentity, checksum: &str, installed_at: &str) -> LockEntry {
873 LockEntry {
874 artifact_type: ArtifactKind::Skill,
875 version: Some(tool.version.clone()),
876 installed_at: installed_at.to_string(),
877 source: LockSource {
878 repo: format!("bundled:{}", tool.name),
879 path: format!("skills/{}", tool.name),
880 },
881 source_checksum: checksum.to_string(),
882 installed_checksum: checksum.to_string(),
883 }
884}
885
886#[cfg(test)]
891mod tests {
892 use super::*;
893 use crate::gateway::Filesystem as _;
894 use crate::skill_fs::SkillFile;
895 use crate::test_support::TestContext;
896 use crate::types::{CmxConfig, InstallScope};
897 use crate::{checksum, config};
898
899 fn make_file(rel: &str, content: &str) -> SkillFile {
900 SkillFile {
901 rel_path: std::path::PathBuf::from(rel),
902 bytes: content.as_bytes().to_vec(),
903 }
904 }
905
906 fn sample_skill(version: &str) -> BundledSkill {
911 BundledSkill::from_files(vec![
912 make_file(
913 "SKILL.md",
914 &format!("---\nmetadata:\n version: \"{version}\"\n---\n# Sample skill\n"),
915 ),
916 make_file("scripts/tool.py", "print('hello')"),
917 ])
918 }
919
920 fn installer(version: &str) -> SkillInstaller {
921 SkillInstaller::new(ToolIdentity {
922 name: "sample".to_string(),
923 version: version.to_string(),
924 })
925 }
926
927 #[test]
932 fn checksum_bundled_matches_checksum_dir_after_write() {
933 let t = TestContext::new();
934 let skill = sample_skill("1.0.0");
935 let expected = skill_fs::checksum_bundled(&skill.files);
936 let dest = std::path::PathBuf::from("/dest/sample");
937 skill_fs::write_skill_files(&dest, &skill.files, &t.fs).unwrap();
938 let on_disk = checksum::checksum_dir(&dest, &t.fs).unwrap();
939 assert_eq!(expected, on_disk, "in-memory checksum must match disk checksum");
940 }
941
942 #[test]
943 fn dotfiles_and_transient_excluded_from_write_and_checksum() {
944 let files = vec![
945 make_file("SKILL.md", "# skill"),
946 make_file(".hidden", "hidden"),
947 make_file("node_modules/dep.js", "vendor"),
948 ];
949 let bundled_cs = skill_fs::checksum_bundled(&files);
950
951 let only_skill = vec![make_file("SKILL.md", "# skill")];
953 let expected_cs = skill_fs::checksum_bundled(&only_skill);
954 assert_eq!(
955 bundled_cs, expected_cs,
956 "dotfiles and transient must be excluded from checksum"
957 );
958 }
959
960 #[test]
961 fn write_skill_files_creates_nested_dirs() {
962 let t = TestContext::new();
963 let files = vec![
964 make_file("SKILL.md", "# skill"),
965 make_file("scripts/sub/tool.py", "code"),
966 ];
967 skill_fs::write_skill_files(std::path::Path::new("/dest/s"), &files, &t.fs).unwrap();
968 assert!(t.fs.file_exists(std::path::Path::new("/dest/s/SKILL.md")));
969 assert!(t.fs.file_exists(std::path::Path::new("/dest/s/scripts/sub/tool.py")));
970 }
971
972 #[test]
977 fn fresh_machine_produces_single_claude_target_install() {
978 let t = TestContext::new();
979 let ctx = t.ctx();
980 let skill = sample_skill("1.0.0");
981 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
982
983 assert_eq!(plan.targets.len(), 1);
984 assert_eq!(plan.targets[0].platform, Platform::Claude);
985 assert!(matches!(plan.targets[0].action, TargetAction::Install));
986 assert!(!plan.cmx_present);
987 }
988
989 #[test]
990 fn cmx_config_two_platforms_produces_two_targets_cmx_managed() {
991 let t = TestContext::new();
992 let cfg = CmxConfig {
993 platforms: vec![Platform::Claude, Platform::Codex],
994 ..Default::default()
995 };
996 config::save_config(&cfg, &t.fs, &t.paths).unwrap();
997
998 let ctx = t.ctx();
999 let skill = sample_skill("1.0.0");
1000 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1001
1002 let platforms: Vec<_> = plan.targets.iter().map(|t| t.platform).collect();
1003 assert!(platforms.contains(&Platform::Claude), "should include Claude");
1004 assert!(platforms.contains(&Platform::Codex), "should include Codex");
1005 assert!(plan.targets[0].cmx_managed, "cmx_managed should be true");
1006 }
1007
1008 #[test]
1009 fn no_config_but_non_empty_codex_lock_targets_codex() {
1010 let t = TestContext::new();
1011 let codex_paths = t.paths.with_platform(Platform::Codex);
1012 crate::test_support::save_lock_with_entry(
1013 &t.fs,
1014 &codex_paths,
1015 "other-skill",
1016 crate::test_support::sample_lock_entry(),
1017 InstallScope::Global,
1018 );
1019
1020 let ctx = t.ctx();
1021 let skill = sample_skill("1.0.0");
1022 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1023
1024 let platforms: Vec<_> = plan.targets.iter().map(|t| t.platform).collect();
1025 assert!(
1026 platforms.contains(&Platform::Codex),
1027 "Codex lock non-empty → should be targeted"
1028 );
1029 }
1030
1031 fn plan_with_locked_version(
1036 t: &TestContext,
1037 locked_version: &str,
1038 locked_checksum: &str,
1039 bundled_version: &str,
1040 force: bool,
1041 ) -> InstallPlan {
1042 let claude_paths = t.paths.with_platform(Platform::Claude);
1044 let skill_dir = claude_paths
1045 .install_dir(ArtifactKind::Skill, InstallScope::Global)
1046 .unwrap()
1047 .join("sample");
1048 t.fs.add_dir(skill_dir.clone());
1049
1050 crate::test_support::save_lock_with_entry(
1051 &t.fs,
1052 &claude_paths,
1053 "sample",
1054 LockEntry {
1055 artifact_type: ArtifactKind::Skill,
1056 version: Some(locked_version.to_string()),
1057 installed_at: "2024-01-01T00:00:00Z".to_string(),
1058 source: LockSource {
1059 repo: "bundled:sample".to_string(),
1060 path: "skills/sample".to_string(),
1061 },
1062 source_checksum: locked_checksum.to_string(),
1063 installed_checksum: locked_checksum.to_string(),
1064 },
1065 InstallScope::Global,
1066 );
1067 let skill = sample_skill(bundled_version);
1068 let ctx = t.ctx();
1069 installer(bundled_version).plan(&skill, Scope::Global, force, &ctx).unwrap()
1070 }
1071
1072 #[test]
1073 fn older_lock_version_produces_update() {
1074 let t = TestContext::new();
1075 let plan = plan_with_locked_version(&t, "0.9.0", "sha256:old", "1.0.0", false);
1076 assert!(matches!(plan.targets[0].action, TargetAction::Update { .. }));
1077 }
1078
1079 #[test]
1080 fn same_version_identical_checksum_on_disk_produces_skip() {
1081 let t = TestContext::new();
1082 let skill = sample_skill("1.0.0");
1083 let checksum = skill_fs::checksum_bundled(&skill.files);
1084
1085 let claude_paths = t.paths.with_platform(Platform::Claude);
1086 let skill_dir = claude_paths
1087 .install_dir(ArtifactKind::Skill, InstallScope::Global)
1088 .unwrap()
1089 .join("sample");
1090 skill_fs::write_skill_files(&skill_dir, &skill.files, &t.fs).unwrap();
1091
1092 crate::test_support::save_lock_with_entry(
1093 &t.fs,
1094 &claude_paths,
1095 "sample",
1096 LockEntry {
1097 artifact_type: ArtifactKind::Skill,
1098 version: Some("1.0.0".to_string()),
1099 installed_at: "2024-01-01T00:00:00Z".to_string(),
1100 source: LockSource {
1101 repo: "bundled:sample".to_string(),
1102 path: "skills/sample".to_string(),
1103 },
1104 source_checksum: checksum.clone(),
1105 installed_checksum: checksum.clone(),
1106 },
1107 InstallScope::Global,
1108 );
1109
1110 let ctx = t.ctx();
1111 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1112 assert!(matches!(plan.targets[0].action, TargetAction::Skip));
1113 }
1114
1115 #[test]
1116 fn same_version_differing_content_no_force_produces_drifted_skip() {
1117 let t = TestContext::new();
1118 let skill = sample_skill("1.0.0");
1119 let checksum = skill_fs::checksum_bundled(&skill.files);
1120
1121 let claude_paths = t.paths.with_platform(Platform::Claude);
1122 let skill_dir = claude_paths
1123 .install_dir(ArtifactKind::Skill, InstallScope::Global)
1124 .unwrap()
1125 .join("sample");
1126 t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Modified\n");
1127
1128 crate::test_support::save_lock_with_entry(
1129 &t.fs,
1130 &claude_paths,
1131 "sample",
1132 LockEntry {
1133 artifact_type: ArtifactKind::Skill,
1134 version: Some("1.0.0".to_string()),
1135 installed_at: "2024-01-01T00:00:00Z".to_string(),
1136 source: LockSource {
1137 repo: "bundled:sample".to_string(),
1138 path: "skills/sample".to_string(),
1139 },
1140 source_checksum: checksum.clone(),
1141 installed_checksum: checksum,
1142 },
1143 InstallScope::Global,
1144 );
1145
1146 let ctx = t.ctx();
1147 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1148 assert!(matches!(plan.targets[0].action, TargetAction::DriftedSkip { .. }));
1149 }
1150
1151 #[test]
1152 fn same_version_differing_content_with_force_produces_update() {
1153 let t = TestContext::new();
1154 let skill = sample_skill("1.0.0");
1155 let checksum = skill_fs::checksum_bundled(&skill.files);
1156
1157 let claude_paths = t.paths.with_platform(Platform::Claude);
1158 let skill_dir = claude_paths
1159 .install_dir(ArtifactKind::Skill, InstallScope::Global)
1160 .unwrap()
1161 .join("sample");
1162 t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Modified\n");
1163
1164 crate::test_support::save_lock_with_entry(
1165 &t.fs,
1166 &claude_paths,
1167 "sample",
1168 LockEntry {
1169 artifact_type: ArtifactKind::Skill,
1170 version: Some("1.0.0".to_string()),
1171 installed_at: "2024-01-01T00:00:00Z".to_string(),
1172 source: LockSource {
1173 repo: "bundled:sample".to_string(),
1174 path: "skills/sample".to_string(),
1175 },
1176 source_checksum: checksum.clone(),
1177 installed_checksum: checksum,
1178 },
1179 InstallScope::Global,
1180 );
1181
1182 let ctx = t.ctx();
1183 let plan = installer("1.0.0").plan(&skill, Scope::Global, true, &ctx).unwrap();
1184 assert!(matches!(plan.targets[0].action, TargetAction::Update { .. }));
1185 }
1186
1187 #[test]
1188 fn newer_lock_no_force_produces_refuse_newer_and_is_blocked() {
1189 let t = TestContext::new();
1190 let plan = plan_with_locked_version(&t, "2.0.0", "sha256:new", "1.0.0", false);
1191 assert!(matches!(plan.targets[0].action, TargetAction::RefuseNewer { .. }));
1192 assert!(plan.is_blocked());
1193 }
1194
1195 #[test]
1196 fn newer_lock_with_force_produces_downgrade() {
1197 let t = TestContext::new();
1198 let plan = plan_with_locked_version(&t, "2.0.0", "sha256:new", "1.0.0", true);
1199 assert!(matches!(plan.targets[0].action, TargetAction::Downgrade { .. }));
1200 assert!(!plan.is_blocked());
1201 }
1202
1203 #[test]
1204 fn non_semver_versions_use_string_equality_fallback() {
1205 let t = TestContext::new();
1207 let skill = sample_skill("v1-alpha");
1208 let checksum = skill_fs::checksum_bundled(&skill.files);
1209
1210 let claude_paths = t.paths.with_platform(Platform::Claude);
1211 let skill_dir = claude_paths
1212 .install_dir(ArtifactKind::Skill, InstallScope::Global)
1213 .unwrap()
1214 .join("sample");
1215 skill_fs::write_skill_files(&skill_dir, &skill.files, &t.fs).unwrap();
1216
1217 crate::test_support::save_lock_with_entry(
1218 &t.fs,
1219 &claude_paths,
1220 "sample",
1221 LockEntry {
1222 artifact_type: ArtifactKind::Skill,
1223 version: Some("v1-alpha".to_string()),
1224 installed_at: "2024-01-01T00:00:00Z".to_string(),
1225 source: LockSource {
1226 repo: "bundled:sample".to_string(),
1227 path: "skills/sample".to_string(),
1228 },
1229 source_checksum: checksum.clone(),
1230 installed_checksum: checksum.clone(),
1231 },
1232 InstallScope::Global,
1233 );
1234
1235 let ctx = t.ctx();
1237 let plan = installer("v1-alpha").plan(&skill, Scope::Global, false, &ctx).unwrap();
1238 assert!(
1245 matches!(plan.targets[0].action, TargetAction::Skip)
1246 || matches!(plan.targets[0].action, TargetAction::Install),
1247 "non-semver equal versions should not produce RefuseNewer or Downgrade"
1248 );
1249 }
1250
1251 #[test]
1252 fn missing_skill_md_returns_error() {
1253 let t = TestContext::new();
1254 let skill = BundledSkill::from_files(vec![make_file("scripts/tool.py", "code")]);
1255 let ctx = t.ctx();
1256 let result = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx);
1257 assert!(result.is_err());
1258 assert!(result.unwrap_err().to_string().contains("SKILL.md"));
1259 }
1260
1261 #[test]
1266 fn apply_fresh_machine_writes_files_and_lock_source_not_registered() {
1267 let t = TestContext::new();
1268 let skill = sample_skill("1.0.0");
1269 let ctx = t.ctx();
1270 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1271 let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1272
1273 assert_eq!(report.applied().count(), 1);
1274 assert!(!report.source_registered, "no managed set → no source registration");
1275
1276 let skill_dir = t
1278 .paths
1279 .install_dir(ArtifactKind::Skill, InstallScope::Global)
1280 .unwrap()
1281 .join("sample");
1282 assert!(t.fs.file_exists(&skill_dir.join("SKILL.md")));
1283
1284 let lock = lockfile::load(InstallScope::Global, &t.fs, &t.paths).unwrap();
1286 assert!(lock.packages.contains_key("sample"));
1287 }
1288
1289 #[test]
1290 fn installed_checksum_equals_source_checksum() {
1291 let t = TestContext::new();
1292 let skill = sample_skill("1.0.0");
1293 let ctx = t.ctx();
1294 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1295 let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1296
1297 let first_applied = report.applied().next().unwrap();
1298 assert_eq!(first_applied.installed_checksum.as_deref().unwrap(), plan.source_checksum);
1299 }
1300
1301 #[test]
1302 fn cmx_managed_registers_source_and_materializes_dir() {
1303 let t = TestContext::new();
1304 let cfg = CmxConfig {
1305 platforms: vec![Platform::Claude],
1306 ..Default::default()
1307 };
1308 config::save_config(&cfg, &t.fs, &t.paths).unwrap();
1309
1310 let skill = sample_skill("1.0.0");
1311 let ctx = t.ctx();
1312 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1313 let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1314
1315 assert!(report.source_registered, "managed set → source should be registered");
1316
1317 let sources = config::load_sources(&t.fs, &t.paths).unwrap();
1318 assert!(sources.sources.contains_key("bundled:sample"), "source entry should exist");
1319 }
1320
1321 #[test]
1322 fn skip_and_drifted_skip_plan_writes_nothing() {
1323 let t = TestContext::new();
1324 let skill = sample_skill("1.0.0");
1325 let checksum = skill_fs::checksum_bundled(&skill.files);
1326
1327 let claude_paths = t.paths.with_platform(Platform::Claude);
1328 let skill_dir = claude_paths
1329 .install_dir(ArtifactKind::Skill, InstallScope::Global)
1330 .unwrap()
1331 .join("sample");
1332 skill_fs::write_skill_files(&skill_dir, &skill.files, &t.fs).unwrap();
1333
1334 crate::test_support::save_lock_with_entry(
1335 &t.fs,
1336 &claude_paths,
1337 "sample",
1338 LockEntry {
1339 artifact_type: ArtifactKind::Skill,
1340 version: Some("1.0.0".to_string()),
1341 installed_at: "2024-01-01T00:00:00Z".to_string(),
1342 source: LockSource {
1343 repo: "bundled:sample".to_string(),
1344 path: "skills/sample".to_string(),
1345 },
1346 source_checksum: checksum.clone(),
1347 installed_checksum: checksum.clone(),
1348 },
1349 InstallScope::Global,
1350 );
1351
1352 let ctx = t.ctx();
1353 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1354 assert!(matches!(plan.targets[0].action, TargetAction::Skip));
1356 assert_eq!(plan.write_count(), 0);
1357 }
1358
1359 #[test]
1360 fn blocked_plan_returns_err_on_apply() {
1361 let t = TestContext::new();
1362 let skill = sample_skill("1.0.0");
1364 let claude_paths = t.paths.with_platform(Platform::Claude);
1365 let skill_dir = claude_paths
1366 .install_dir(ArtifactKind::Skill, InstallScope::Global)
1367 .unwrap()
1368 .join("sample");
1369 t.fs.add_dir(skill_dir);
1370
1371 crate::test_support::save_lock_with_entry(
1372 &t.fs,
1373 &claude_paths,
1374 "sample",
1375 LockEntry {
1376 artifact_type: ArtifactKind::Skill,
1377 version: Some("2.0.0".to_string()),
1378 installed_at: "2024-01-01T00:00:00Z".to_string(),
1379 source: LockSource {
1380 repo: "bundled:sample".to_string(),
1381 path: "skills/sample".to_string(),
1382 },
1383 source_checksum: "sha256:abc".to_string(),
1384 installed_checksum: "sha256:abc".to_string(),
1385 },
1386 InstallScope::Global,
1387 );
1388
1389 let ctx = t.ctx();
1390 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1391 assert!(plan.is_blocked());
1392
1393 let result = installer("1.0.0").apply(&skill, &plan, &ctx);
1394 assert!(result.is_err());
1395 }
1396
1397 #[test]
1398 fn parity_guard_rejects_mismatched_bundled_skill() {
1399 let t = TestContext::new();
1400 let skill_v1 = sample_skill("1.0.0");
1401 let ctx = t.ctx();
1402 let plan = installer("1.0.0").plan(&skill_v1, Scope::Global, false, &ctx).unwrap();
1403
1404 let skill_v2 = BundledSkill::from_files(vec![
1408 make_file("SKILL.md", "---\nmetadata:\n version: \"1.0.0\"\n---\n# DIFFERENT body\n"),
1409 make_file("scripts/tool.py", "print('hello')"),
1410 ]);
1411 let result = installer("1.0.0").apply(&skill_v2, &plan, &ctx);
1412 assert!(result.is_err());
1413 assert!(result.unwrap_err().to_string().contains("Parity"));
1414 }
1415
1416 #[test]
1417 fn shared_dir_managed_codex_pi_written_once_both_locks_updated() {
1418 let t = TestContext::new();
1419 let cfg = CmxConfig {
1421 platforms: vec![Platform::Codex, Platform::Pi],
1422 ..Default::default()
1423 };
1424 config::save_config(&cfg, &t.fs, &t.paths).unwrap();
1425
1426 let skill = sample_skill("1.0.0");
1427 let ctx = t.ctx();
1428 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1429 installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1430
1431 let codex_paths = t.paths.with_platform(Platform::Codex);
1433 let pi_paths = t.paths.with_platform(Platform::Pi);
1434
1435 let codex_lock = lockfile::load(InstallScope::Global, &t.fs, &codex_paths).unwrap();
1436 let pi_lock = lockfile::load(InstallScope::Global, &t.fs, &pi_paths).unwrap();
1437
1438 assert!(codex_lock.packages.contains_key("sample"), "Codex lock should have entry");
1439 assert!(pi_lock.packages.contains_key("sample"), "Pi lock should have entry");
1440 }
1441
1442 #[test]
1443 fn on_disk_file_set_matches_planned_dest_paths() {
1444 let t = TestContext::new();
1445 let skill = sample_skill("1.0.0");
1446 let ctx = t.ctx();
1447 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1448 installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1449
1450 for target in &plan.targets {
1451 for pf in &target.files {
1452 assert!(
1453 t.fs.file_exists(&pf.dest_path),
1454 "expected file on disk: {}",
1455 pf.dest_path.display()
1456 );
1457 }
1458 }
1459 }
1460
1461 #[test]
1466 fn not_installed_on_fresh_machine() {
1467 let t = TestContext::new();
1468 let ctx = t.ctx();
1469 let status = installer("1.0.0").status(Scope::Global, &ctx).unwrap();
1470 assert!(!status.targets[0].installed);
1471 assert!(!status.targets[0].tracked);
1472 }
1473
1474 #[test]
1475 fn after_apply_installed_tracked_version_matches_not_drifted() {
1476 let t = TestContext::new();
1477 let skill = sample_skill("1.0.0");
1478 let ctx = t.ctx();
1479 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1480 installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1481
1482 let status = installer("1.0.0").status(Scope::Global, &ctx).unwrap();
1483 assert!(status.targets[0].installed);
1484 assert!(status.targets[0].tracked);
1485 assert_eq!(status.targets[0].installed_version.as_deref(), Some("1.0.0"));
1486 assert!(!status.targets[0].drifted);
1487 }
1488
1489 #[test]
1490 fn mutate_skill_md_on_disk_produces_drifted() {
1491 let t = TestContext::new();
1492 let skill = sample_skill("1.0.0");
1493 let ctx = t.ctx();
1494 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1495 installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1496
1497 let skill_dir = t
1499 .paths
1500 .install_dir(ArtifactKind::Skill, InstallScope::Global)
1501 .unwrap()
1502 .join("sample");
1503 t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# MODIFIED\n");
1504
1505 let status = installer("1.0.0").status(Scope::Global, &ctx).unwrap();
1506 assert!(status.targets[0].drifted, "mutated SKILL.md should report drifted");
1507 }
1508
1509 #[test]
1514 fn remove_deletes_dir_and_clears_lock() {
1515 let t = TestContext::new();
1516 let skill = sample_skill("1.0.0");
1517 let ctx = t.ctx();
1518 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1519 installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1520
1521 let skill_dir = t
1522 .paths
1523 .install_dir(ArtifactKind::Skill, InstallScope::Global)
1524 .unwrap()
1525 .join("sample");
1526 assert!(t.fs.exists(&skill_dir));
1527
1528 let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1529 assert!(report.was_on_disk);
1530 assert!(report.was_tracked);
1531 assert!(!t.fs.exists(&skill_dir));
1532
1533 let lock = lockfile::load(InstallScope::Global, &t.fs, &t.paths).unwrap();
1534 assert!(!lock.packages.contains_key("sample"));
1535 }
1536
1537 #[test]
1538 fn shared_dir_managed_codex_pi_removed_once_both_locks_cleared() {
1539 let t = TestContext::new();
1540 let cfg = CmxConfig {
1541 platforms: vec![Platform::Codex, Platform::Pi],
1542 ..Default::default()
1543 };
1544 config::save_config(&cfg, &t.fs, &t.paths).unwrap();
1545
1546 let skill = sample_skill("1.0.0");
1547 let ctx = t.ctx();
1548 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1549 installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1550
1551 let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1552 assert!(report.was_on_disk);
1553 assert!(report.platforms_cleared.contains(&Platform::Codex));
1554 assert!(report.platforms_cleared.contains(&Platform::Pi));
1555
1556 let codex_paths = t.paths.with_platform(Platform::Codex);
1558 let pi_paths = t.paths.with_platform(Platform::Pi);
1559 let codex_lock = lockfile::load(InstallScope::Global, &t.fs, &codex_paths).unwrap();
1560 let pi_lock = lockfile::load(InstallScope::Global, &t.fs, &pi_paths).unwrap();
1561 assert!(!codex_lock.packages.contains_key("sample"));
1562 assert!(!pi_lock.packages.contains_key("sample"));
1563 }
1564
1565 #[test]
1566 fn cmx_managed_remove_clears_source_and_materialized_dir() {
1567 let t = TestContext::new();
1568 let cfg = CmxConfig {
1569 platforms: vec![Platform::Claude],
1570 ..Default::default()
1571 };
1572 config::save_config(&cfg, &t.fs, &t.paths).unwrap();
1573
1574 let skill = sample_skill("1.0.0");
1575 let ctx = t.ctx();
1576 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1577 installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1578
1579 let sources = config::load_sources(&t.fs, &t.paths).unwrap();
1581 assert!(sources.sources.contains_key("bundled:sample"));
1582
1583 let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1584 assert!(report.source_unregistered);
1585
1586 let sources_after = config::load_sources(&t.fs, &t.paths).unwrap();
1587 assert!(!sources_after.sources.contains_key("bundled:sample"));
1588 }
1589
1590 #[test]
1591 fn remove_when_nothing_installed_returns_ok_all_false() {
1592 let t = TestContext::new();
1593 let ctx = t.ctx();
1594 let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1595 assert!(!report.was_on_disk);
1596 assert!(!report.was_tracked);
1597 assert!(!report.source_unregistered);
1598 }
1599
1600 #[test]
1605 fn single_md_builds_single_skill_md() {
1606 let skill = BundledSkill::single_md("---\nversion: 1.0.0\n---\n# My skill\n");
1607 assert_eq!(skill.files.len(), 1);
1608 assert!(skill.has_skill_md());
1609 assert_eq!(skill.files[0].rel_path, std::path::PathBuf::from("SKILL.md"));
1610 }
1611
1612 #[test]
1613 fn tool_identity_new_sets_fields() {
1614 let id = ToolIdentity::new("mytool", "1.2.3");
1615 assert_eq!(id.name, "mytool");
1616 assert_eq!(id.version, "1.2.3");
1617 }
1618
1619 #[test]
1620 fn scope_partial_eq() {
1621 assert_eq!(Scope::Global, Scope::Global);
1622 assert_eq!(Scope::Local, Scope::Local);
1623 assert_ne!(Scope::Global, Scope::Local);
1624 }
1625
1626 #[test]
1631 fn skipped_target_outcome_carries_dest_dir() {
1632 let t = TestContext::new();
1633 let skill = sample_skill("1.0.0");
1634 let checksum = skill_fs::checksum_bundled(&skill.files);
1635
1636 let claude_paths = t.paths.with_platform(Platform::Claude);
1637 let skill_dir = claude_paths
1638 .install_dir(ArtifactKind::Skill, InstallScope::Global)
1639 .unwrap()
1640 .join("sample");
1641 skill_fs::write_skill_files(&skill_dir, &skill.files, &t.fs).unwrap();
1642
1643 crate::test_support::save_lock_with_entry(
1644 &t.fs,
1645 &claude_paths,
1646 "sample",
1647 LockEntry {
1648 artifact_type: ArtifactKind::Skill,
1649 version: Some("1.0.0".to_string()),
1650 installed_at: "2024-01-01T00:00:00Z".to_string(),
1651 source: LockSource {
1652 repo: "bundled:sample".to_string(),
1653 path: "skills/sample".to_string(),
1654 },
1655 source_checksum: checksum.clone(),
1656 installed_checksum: checksum.clone(),
1657 },
1658 InstallScope::Global,
1659 );
1660
1661 let ctx = t.ctx();
1662 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1663 let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1664
1665 let skip = report.skipped().next().expect("expected a skipped target");
1667 assert!(
1668 !skip.dest_dir.as_os_str().is_empty(),
1669 "dest_dir must be non-empty on skipped target"
1670 );
1671 assert!(matches!(skip.action, TargetAction::Skip));
1672 assert_eq!(skip.installed_checksum, None);
1673 }
1674
1675 #[test]
1676 fn drifted_skip_outcome_is_distinguishable_from_plain_skip() {
1677 let t = TestContext::new();
1678 let skill = sample_skill("1.0.0");
1679 let checksum = skill_fs::checksum_bundled(&skill.files);
1680
1681 let claude_paths = t.paths.with_platform(Platform::Claude);
1682 let skill_dir = claude_paths
1683 .install_dir(ArtifactKind::Skill, InstallScope::Global)
1684 .unwrap()
1685 .join("sample");
1686 t.fs.add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Modified\n");
1687
1688 crate::test_support::save_lock_with_entry(
1689 &t.fs,
1690 &claude_paths,
1691 "sample",
1692 LockEntry {
1693 artifact_type: ArtifactKind::Skill,
1694 version: Some("1.0.0".to_string()),
1695 installed_at: "2024-01-01T00:00:00Z".to_string(),
1696 source: LockSource {
1697 repo: "bundled:sample".to_string(),
1698 path: "skills/sample".to_string(),
1699 },
1700 source_checksum: checksum.clone(),
1701 installed_checksum: checksum,
1702 },
1703 InstallScope::Global,
1704 );
1705
1706 let ctx = t.ctx();
1707 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1708 let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1709
1710 let skip = report.skipped().next().expect("expected a skipped target");
1711 assert!(
1712 matches!(skip.action, TargetAction::DriftedSkip { .. }),
1713 "action must be DriftedSkip, not plain Skip"
1714 );
1715 }
1716
1717 #[test]
1718 fn force_overwrites_drifted_copy_and_reports_update() {
1719 let t = TestContext::new();
1720 let skill = sample_skill("1.0.0");
1721 let checksum = skill_fs::checksum_bundled(&skill.files);
1722
1723 let claude_paths = t.paths.with_platform(Platform::Claude);
1724 let skill_dir = claude_paths
1725 .install_dir(ArtifactKind::Skill, InstallScope::Global)
1726 .unwrap()
1727 .join("sample");
1728 let skill_md = skill_dir.join("SKILL.md");
1729 let local_only = skill_dir.join("local-only.md");
1730 t.fs.add_file(&skill_md, "---\nversion: 1.0.0\n---\n# Modified\n");
1731 t.fs.add_file(&local_only, "scratch\n");
1732
1733 crate::test_support::save_lock_with_entry(
1734 &t.fs,
1735 &claude_paths,
1736 "sample",
1737 LockEntry {
1738 artifact_type: ArtifactKind::Skill,
1739 version: Some("1.0.0".to_string()),
1740 installed_at: "2024-01-01T00:00:00Z".to_string(),
1741 source: LockSource {
1742 repo: "bundled:sample".to_string(),
1743 path: "skills/sample".to_string(),
1744 },
1745 source_checksum: checksum.clone(),
1746 installed_checksum: checksum.clone(),
1747 },
1748 InstallScope::Global,
1749 );
1750
1751 let ctx = t.ctx();
1752 let plan = installer("1.0.0").plan(&skill, Scope::Global, true, &ctx).unwrap();
1753 let report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1754
1755 let updated = report.applied().next().expect("expected an updated target");
1756 assert!(matches!(updated.action, TargetAction::Update { .. }));
1757 let discarded: BTreeSet<_> = updated.discarded_paths.iter().cloned().collect();
1758 assert_eq!(
1759 discarded,
1760 BTreeSet::from([
1761 local_only.clone(),
1762 skill_md.clone(),
1763 skill_dir.join("scripts/tool.py")
1764 ])
1765 );
1766 assert_eq!(
1767 t.fs.read_to_string(&skill_md).unwrap(),
1768 "---\nmetadata:\n version: \"1.0.0\"\n---\n# Sample skill\n"
1769 );
1770 assert!(!t.fs.exists(&local_only));
1771 assert_eq!(checksum::checksum_dir(&skill_dir, &t.fs).unwrap(), checksum);
1772 }
1773
1774 #[test]
1779 fn install_plan_display_contains_target_lines() {
1780 let t = TestContext::new();
1781 let skill = sample_skill("1.0.0");
1782 let ctx = t.ctx();
1783 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1784 let rendered = plan.to_string();
1785 assert!(rendered.contains("sample"), "plan display must include tool name");
1786 assert!(rendered.contains("1.0.0"), "plan display must include version");
1787 assert!(rendered.contains("install"), "plan display must include action");
1788 }
1789
1790 #[test]
1791 fn report_display_distinguishes_drifted_skip() {
1792 let t = TestContext::new();
1793 let skill = sample_skill("1.0.0");
1794
1795 let ctx = t.ctx();
1797 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1798 let up_to_date_report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1799 let up_to_date_text = up_to_date_report.to_string();
1800
1801 let plan2 = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1803 let skip_report = installer("1.0.0").apply(&skill, &plan2, &ctx).unwrap();
1804 let skip_text = skip_report.to_string();
1805 assert!(skip_text.contains("up to date"), "up-to-date skip must say 'up to date'");
1806
1807 let t2 = TestContext::new();
1809 let checksum = skill_fs::checksum_bundled(&skill.files);
1810 let claude_paths = t2.paths.with_platform(Platform::Claude);
1811 let skill_dir = claude_paths
1812 .install_dir(ArtifactKind::Skill, InstallScope::Global)
1813 .unwrap()
1814 .join("sample");
1815 t2.fs
1816 .add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Modified\n");
1817 crate::test_support::save_lock_with_entry(
1818 &t2.fs,
1819 &claude_paths,
1820 "sample",
1821 LockEntry {
1822 artifact_type: ArtifactKind::Skill,
1823 version: Some("1.0.0".to_string()),
1824 installed_at: "2024-01-01T00:00:00Z".to_string(),
1825 source: LockSource {
1826 repo: "bundled:sample".to_string(),
1827 path: "skills/sample".to_string(),
1828 },
1829 source_checksum: checksum.clone(),
1830 installed_checksum: checksum,
1831 },
1832 InstallScope::Global,
1833 );
1834 let ctx2 = t2.ctx();
1835 let drifted_plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx2).unwrap();
1836 let drifted_report = installer("1.0.0").apply(&skill, &drifted_plan, &ctx2).unwrap();
1837 let drifted_text = drifted_report.to_string();
1838
1839 assert!(
1841 drifted_text.contains("drifted"),
1842 "drifted skip must mention 'drifted' in output, got: {drifted_text}"
1843 );
1844 assert_ne!(
1845 skip_text, drifted_text,
1846 "up-to-date skip and drifted skip must produce different display output"
1847 );
1848 let _ = up_to_date_text;
1849 }
1850
1851 #[test]
1852 fn remove_report_display_notes_lockfile_left() {
1853 let t = TestContext::new();
1854 let skill = sample_skill("1.0.0");
1855 let ctx = t.ctx();
1856 let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
1857 installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
1858
1859 let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
1860 let rendered = report.to_string();
1861 assert!(
1862 rendered.contains("cmx-lock.json"),
1863 "remove report must note the lockfile is left on disk"
1864 );
1865 }
1866}