1use std::path::Path;
8
9use crate::error::SoukError;
10use crate::resolution::skill::enumerate_skills;
11use crate::review::provider::LlmProvider;
12
13#[derive(Debug, Clone)]
15pub struct ReviewReport {
16 pub plugin_name: String,
18 pub provider_name: String,
20 pub model_name: String,
22 pub review_text: String,
24}
25
26pub fn review_plugin(
37 plugin_path: &Path,
38 provider: &dyn LlmProvider,
39 output_dir: Option<&Path>,
40) -> Result<ReviewReport, SoukError> {
41 let plugin_json_path = plugin_path.join(".claude-plugin").join("plugin.json");
43 let plugin_json = std::fs::read_to_string(&plugin_json_path)?;
44
45 let extends_path = plugin_path
47 .join(".claude-plugin")
48 .join("extends-plugin.json");
49 let extends_json = std::fs::read_to_string(&extends_path).ok();
50
51 let readme_path = plugin_path.join("README.md");
53 let readme = std::fs::read_to_string(&readme_path).ok();
54
55 let skills = enumerate_skills(plugin_path);
57 let skills_summary: Vec<String> = skills
58 .iter()
59 .map(|s| format!("- {} (dir: {})", s.display_name, s.dir_name))
60 .collect();
61
62 let prompt = build_plugin_review_prompt(
64 &plugin_json,
65 extends_json.as_deref(),
66 readme.as_deref(),
67 &skills_summary,
68 );
69
70 let review_text = provider.complete(&prompt)?;
72
73 let plugin_name = plugin_path
75 .file_name()
76 .map(|n| n.to_string_lossy().to_string())
77 .unwrap_or_else(|| "unknown".to_string());
78
79 let report = ReviewReport {
80 plugin_name: plugin_name.clone(),
81 provider_name: provider.name().to_string(),
82 model_name: provider.model().to_string(),
83 review_text: review_text.clone(),
84 };
85
86 if let Some(dir) = output_dir {
88 std::fs::create_dir_all(dir)?;
89 let report_path = dir.join(format!("{plugin_name}-review-report.md"));
90 let report_content = format!(
91 "# Plugin Review: {plugin_name}\n\n\
92 **Provider:** {} ({})\n\
93 **Date:** {}\n\n\
94 ---\n\n\
95 {review_text}\n",
96 report.provider_name,
97 report.model_name,
98 current_date_string(),
99 );
100 std::fs::write(&report_path, report_content)?;
101 }
102
103 Ok(report)
104}
105
106pub fn build_plugin_review_prompt(
111 plugin_json: &str,
112 extends_json: Option<&str>,
113 readme: Option<&str>,
114 skills: &[String],
115) -> String {
116 let mut prompt = String::with_capacity(2048);
117
118 prompt.push_str(
119 "You are a senior code reviewer specializing in Claude Code plugins. \
120 Review this plugin for quality, security, and best practices.\n\n",
121 );
122
123 prompt.push_str("## plugin.json\n```json\n");
124 prompt.push_str(plugin_json);
125 prompt.push_str("\n```\n\n");
126
127 if let Some(extends) = extends_json {
128 prompt.push_str("## extends-plugin.json\n```json\n");
129 prompt.push_str(extends);
130 prompt.push_str("\n```\n\n");
131 }
132
133 if let Some(readme) = readme {
134 prompt.push_str("## README.md\n");
135 prompt.push_str(readme);
136 prompt.push_str("\n\n");
137 }
138
139 if !skills.is_empty() {
140 prompt.push_str("## Skills\n");
141 for skill in skills {
142 prompt.push_str(skill);
143 prompt.push('\n');
144 }
145 prompt.push('\n');
146 }
147
148 prompt.push_str(
149 "Please provide:\n\
150 1. Executive Summary\n\
151 2. Component Analysis (agents, skills, commands, hooks, MCP servers)\n\
152 3. Code Quality Assessment\n\
153 4. Documentation Review\n\
154 5. Security Considerations\n\
155 6. Recommendations (critical issues, suggested improvements, optional enhancements)\n\
156 7. Overall Rating (1-10)\n",
157 );
158
159 prompt
160}
161
162fn current_date_string() -> String {
166 let now = std::time::SystemTime::now();
167 let since_epoch = now
168 .duration_since(std::time::UNIX_EPOCH)
169 .unwrap_or_default();
170 let secs = since_epoch.as_secs();
171 let days = secs / 86400;
173
174 let (year, month, day) = days_to_civil(days as i64);
176 format!("{year:04}-{month:02}-{day:02}")
177}
178
179fn days_to_civil(days: i64) -> (i32, u32, u32) {
183 let z = days + 719468;
184 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
185 let doe = (z - era * 146097) as u32;
186 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
187 let y = yoe as i64 + era * 400;
188 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
189 let mp = (5 * doy + 2) / 153;
190 let d = doy - (153 * mp + 2) / 5 + 1;
191 let m = if mp < 10 { mp + 3 } else { mp - 9 };
192 let y = if m <= 2 { y + 1 } else { y };
193 (y as i32, m, d)
194}
195
196#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::review::provider::MockProvider;
204 use tempfile::TempDir;
205
206 fn setup_plugin(tmp: &TempDir) -> std::path::PathBuf {
208 let plugin = tmp.path().join("test-plugin");
209 let claude_dir = plugin.join(".claude-plugin");
210 std::fs::create_dir_all(&claude_dir).unwrap();
211 std::fs::write(
212 claude_dir.join("plugin.json"),
213 r#"{"name": "test-plugin", "version": "1.0.0", "description": "A test plugin"}"#,
214 )
215 .unwrap();
216 plugin
217 }
218
219 fn setup_full_plugin(tmp: &TempDir) -> std::path::PathBuf {
221 let plugin = setup_plugin(tmp);
222
223 std::fs::write(
225 plugin.join(".claude-plugin").join("extends-plugin.json"),
226 r#"{"dependencies": {"some-dep": "^1.0.0"}}"#,
227 )
228 .unwrap();
229
230 std::fs::write(
232 plugin.join("README.md"),
233 "# Test Plugin\n\nA plugin for testing.",
234 )
235 .unwrap();
236
237 let skill_dir = plugin.join("skills").join("my-skill");
239 std::fs::create_dir_all(&skill_dir).unwrap();
240 std::fs::write(
241 skill_dir.join("SKILL.md"),
242 "---\nname: My Skill\ndescription: Does things\n---\n# My Skill",
243 )
244 .unwrap();
245
246 plugin
247 }
248
249 #[test]
250 fn review_plugin_builds_report() {
251 let tmp = TempDir::new().unwrap();
252 let plugin = setup_full_plugin(&tmp);
253 let provider = MockProvider::new("Great plugin! Rating: 9/10");
254
255 let report = review_plugin(&plugin, &provider, None).unwrap();
256
257 assert_eq!(report.plugin_name, "test-plugin");
258 assert_eq!(report.provider_name, "mock");
259 assert_eq!(report.model_name, "mock-model");
260 assert_eq!(report.review_text, "Great plugin! Rating: 9/10");
261 }
262
263 #[test]
264 fn review_plugin_saves_report_to_output_dir() {
265 let tmp = TempDir::new().unwrap();
266 let plugin = setup_full_plugin(&tmp);
267 let output_dir = tmp.path().join("output");
268 let provider = MockProvider::new("Looks good!");
269
270 let report = review_plugin(&plugin, &provider, Some(&output_dir)).unwrap();
271
272 assert_eq!(report.plugin_name, "test-plugin");
273
274 let report_path = output_dir.join("test-plugin-review-report.md");
275 assert!(report_path.exists(), "Report file should be created");
276
277 let content = std::fs::read_to_string(&report_path).unwrap();
278 assert!(content.contains("# Plugin Review: test-plugin"));
279 assert!(content.contains("**Provider:** mock (mock-model)"));
280 assert!(content.contains("Looks good!"));
281 }
282
283 #[test]
284 fn review_plugin_minimal_plugin_no_extras() {
285 let tmp = TempDir::new().unwrap();
286 let plugin = setup_plugin(&tmp);
287 let provider = MockProvider::new("Minimal but valid.");
288
289 let report = review_plugin(&plugin, &provider, None).unwrap();
290
291 assert_eq!(report.plugin_name, "test-plugin");
292 assert_eq!(report.review_text, "Minimal but valid.");
293 }
294
295 #[test]
296 fn review_plugin_missing_plugin_json_returns_error() {
297 let tmp = TempDir::new().unwrap();
298 let plugin = tmp.path().join("no-plugin");
299 std::fs::create_dir_all(&plugin).unwrap();
300 let provider = MockProvider::new("should not reach");
301
302 let result = review_plugin(&plugin, &provider, None);
303 assert!(result.is_err());
304 }
305
306 #[test]
307 fn build_prompt_contains_plugin_json() {
308 let prompt = build_plugin_review_prompt(r#"{"name": "foo"}"#, None, None, &[]);
309 assert!(prompt.contains("## plugin.json"));
310 assert!(prompt.contains(r#"{"name": "foo"}"#));
311 }
312
313 #[test]
314 fn build_prompt_includes_extends_when_present() {
315 let prompt = build_plugin_review_prompt(
316 r#"{"name": "foo"}"#,
317 Some(r#"{"dependencies": {}}"#),
318 None,
319 &[],
320 );
321 assert!(prompt.contains("## extends-plugin.json"));
322 assert!(prompt.contains(r#"{"dependencies": {}}"#));
323 }
324
325 #[test]
326 fn build_prompt_includes_readme_when_present() {
327 let prompt = build_plugin_review_prompt(
328 r#"{"name": "foo"}"#,
329 None,
330 Some("# My Plugin\n\nHello world."),
331 &[],
332 );
333 assert!(prompt.contains("## README.md"));
334 assert!(prompt.contains("Hello world."));
335 }
336
337 #[test]
338 fn build_prompt_includes_skills_when_present() {
339 let skills = vec![
340 "- commit-message (dir: git-commit)".to_string(),
341 "- code-review (dir: code-review)".to_string(),
342 ];
343 let prompt = build_plugin_review_prompt(r#"{"name": "foo"}"#, None, None, &skills);
344 assert!(prompt.contains("## Skills"));
345 assert!(prompt.contains("commit-message"));
346 assert!(prompt.contains("code-review"));
347 }
348
349 #[test]
350 fn build_prompt_omits_optional_sections_when_absent() {
351 let prompt = build_plugin_review_prompt(r#"{"name": "foo"}"#, None, None, &[]);
352 assert!(!prompt.contains("## extends-plugin.json"));
353 assert!(!prompt.contains("## README.md"));
354 assert!(!prompt.contains("## Skills"));
355 }
356
357 #[test]
358 fn build_prompt_requests_all_review_sections() {
359 let prompt = build_plugin_review_prompt(r#"{"name": "foo"}"#, None, None, &[]);
360 assert!(prompt.contains("Executive Summary"));
361 assert!(prompt.contains("Component Analysis"));
362 assert!(prompt.contains("Code Quality Assessment"));
363 assert!(prompt.contains("Documentation Review"));
364 assert!(prompt.contains("Security Considerations"));
365 assert!(prompt.contains("Recommendations"));
366 assert!(prompt.contains("Overall Rating (1-10)"));
367 }
368
369 #[test]
370 fn current_date_string_has_correct_format() {
371 let date = current_date_string();
372 assert_eq!(date.len(), 10);
374 assert_eq!(&date[4..5], "-");
375 assert_eq!(&date[7..8], "-");
376 let year: i32 = date[..4].parse().unwrap();
378 assert!(year >= 2020);
379 }
380}