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#[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 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 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 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 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}