Skip to main content

cmx_core/skill_install/
display.rs

1use super::{InstallPlan, RemoveReport, Report, TargetAction};
2
3impl std::fmt::Display for InstallPlan {
4    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5        writeln!(f, "Install plan for {} v{}", self.tool.name, self.tool.version)?;
6        writeln!(f, "  scope: {}", self.scope.label())?;
7        writeln!(f, "  checksum: {}", self.source_checksum)?;
8        for target in &self.targets {
9            writeln!(
10                f,
11                "  {} → {} ({})",
12                target.platform,
13                target.dest_dir.display(),
14                format_action(&target.action)
15            )?;
16        }
17        Ok(())
18    }
19}
20
21fn format_action(action: &TargetAction) -> String {
22    match action {
23        TargetAction::Install => "install".to_string(),
24        TargetAction::Update { from } => {
25            format!("update from {}", from.as_deref().unwrap_or("?"))
26        }
27        TargetAction::Skip => "skip (up to date)".to_string(),
28        TargetAction::DriftedSkip { installed } => {
29            format!("skip (drifted from {installed})")
30        }
31        TargetAction::RefuseNewer { installed } => {
32            format!("refuse (installed {installed} is newer)")
33        }
34        TargetAction::Downgrade { from } => format!("downgrade from {from}"),
35    }
36}
37
38impl std::fmt::Display for Report {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        writeln!(
41            f,
42            "Installed {} v{} ({})",
43            self.tool.name,
44            self.tool.version,
45            self.scope.label()
46        )?;
47        for outcome in &self.targets {
48            writeln!(
49                f,
50                "  {} → {} ({})",
51                outcome.platform,
52                outcome.dest_dir.display(),
53                format_action(&outcome.action)
54            )?;
55        }
56        if self.source_registered {
57            writeln!(f, "  (registered as cmx source)")?;
58        }
59        Ok(())
60    }
61}
62
63impl std::fmt::Display for RemoveReport {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        writeln!(f, "Removed {} ({})", self.tool_name, self.scope.label())?;
66        for platform in &self.platforms_cleared {
67            writeln!(f, "  {platform} lock entry cleared")?;
68        }
69        for dir in &self.removed_dirs {
70            writeln!(f, "  removed: {}", dir.display())?;
71        }
72        if self.source_unregistered {
73            writeln!(f, "  unregistered from cmx sources")?;
74        }
75        writeln!(f, "  note: cmx-lock.json left on disk (shared with other tools)")?;
76        Ok(())
77    }
78}
79
80// ---------------------------------------------------------------------------
81// Display tests
82// ---------------------------------------------------------------------------
83
84#[cfg(test)]
85mod tests {
86    use super::super::{ArtifactKind, BundledSkill, Platform, Scope, SkillInstaller, ToolIdentity};
87    use crate::skill_fs;
88    use crate::skill_fs::SkillFile;
89    use crate::test_support::TestContext;
90    use crate::types::{InstallScope, LockEntry, LockSource};
91
92    fn make_file(rel: &str, content: &str) -> SkillFile {
93        SkillFile {
94            rel_path: std::path::PathBuf::from(rel),
95            bytes: content.as_bytes().to_vec(),
96        }
97    }
98
99    fn sample_skill(version: &str) -> BundledSkill {
100        BundledSkill::from_files(vec![
101            make_file(
102                "SKILL.md",
103                &format!("---\nmetadata:\n  version: \"{version}\"\n---\n# Sample skill\n"),
104            ),
105            make_file("scripts/tool.py", "print('hello')"),
106        ])
107    }
108
109    fn installer(version: &str) -> SkillInstaller {
110        SkillInstaller::new(ToolIdentity {
111            name: "sample".to_string(),
112            version: version.to_string(),
113        })
114    }
115
116    #[test]
117    fn install_plan_display_contains_target_lines() {
118        let t = TestContext::new();
119        let skill = sample_skill("1.0.0");
120        let ctx = t.ctx();
121        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
122        let rendered = plan.to_string();
123        assert!(rendered.contains("sample"), "plan display must include tool name");
124        assert!(rendered.contains("1.0.0"), "plan display must include version");
125        assert!(rendered.contains("install"), "plan display must include action");
126    }
127
128    #[test]
129    fn report_display_distinguishes_drifted_skip() {
130        let t = TestContext::new();
131        let skill = sample_skill("1.0.0");
132
133        // First apply: fresh install
134        let ctx = t.ctx();
135        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
136        let up_to_date_report = installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
137        let up_to_date_text = up_to_date_report.to_string();
138
139        // Second apply (same version, same checksum) → Skip
140        let plan2 = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
141        let skip_report = installer("1.0.0").apply(&skill, &plan2, &ctx).unwrap();
142        let skip_text = skip_report.to_string();
143        assert!(skip_text.contains("up to date"), "up-to-date skip must say 'up to date'");
144
145        // Set up drifted scenario
146        let t2 = TestContext::new();
147        let checksum = skill_fs::checksum_bundled(&skill.files);
148        let claude_paths = t2.paths.with_platform(Platform::Claude);
149        let skill_dir = claude_paths
150            .install_dir(ArtifactKind::Skill, InstallScope::Global)
151            .unwrap()
152            .join("sample");
153        t2.fs
154            .add_file(skill_dir.join("SKILL.md"), "---\nversion: 1.0.0\n---\n# Modified\n");
155        crate::test_support::save_lock_with_entry(
156            &t2.fs,
157            &claude_paths,
158            "sample",
159            LockEntry {
160                artifact_type: ArtifactKind::Skill,
161                version: Some("1.0.0".to_string()),
162                installed_at: "2024-01-01T00:00:00Z".to_string(),
163                source: LockSource {
164                    repo: "bundled:sample".to_string(),
165                    path: "skills/sample".to_string(),
166                },
167                source_checksum: checksum.clone(),
168                installed_checksum: checksum,
169            },
170            InstallScope::Global,
171        );
172        let ctx2 = t2.ctx();
173        let drifted_plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx2).unwrap();
174        let drifted_report = installer("1.0.0").apply(&skill, &drifted_plan, &ctx2).unwrap();
175        let drifted_text = drifted_report.to_string();
176
177        // Drifted display must differ from up-to-date skip display
178        assert!(
179            drifted_text.contains("drifted"),
180            "drifted skip must mention 'drifted' in output, got: {drifted_text}"
181        );
182        assert_ne!(
183            skip_text, drifted_text,
184            "up-to-date skip and drifted skip must produce different display output"
185        );
186        let _ = up_to_date_text;
187    }
188
189    #[test]
190    fn remove_report_display_notes_lockfile_left() {
191        let t = TestContext::new();
192        let skill = sample_skill("1.0.0");
193        let ctx = t.ctx();
194        let plan = installer("1.0.0").plan(&skill, Scope::Global, false, &ctx).unwrap();
195        installer("1.0.0").apply(&skill, &plan, &ctx).unwrap();
196
197        let report = installer("1.0.0").remove(Scope::Global, &ctx).unwrap();
198        let rendered = report.to_string();
199        assert!(
200            rendered.contains("cmx-lock.json"),
201            "remove report must note the lockfile is left on disk"
202        );
203    }
204}