Skip to main content

souk_core/review/
plugin.rs

1//! Plugin review via LLM providers.
2//!
3//! Reads plugin content (plugin.json, extends-plugin.json, README, skills),
4//! builds a structured prompt, sends it to an LLM provider, and optionally
5//! saves the resulting review report to disk.
6
7use std::path::Path;
8
9use crate::error::SoukError;
10use crate::resolution::skill::enumerate_skills;
11use crate::review::provider::LlmProvider;
12
13/// The result of reviewing a plugin with an LLM provider.
14#[derive(Debug, Clone)]
15pub struct ReviewReport {
16    /// Name of the reviewed plugin (derived from directory name).
17    pub plugin_name: String,
18    /// Provider used for the review (e.g., "anthropic").
19    pub provider_name: String,
20    /// Model used for the review (e.g., "claude-sonnet-4-20250514").
21    pub model_name: String,
22    /// The full review text returned by the LLM.
23    pub review_text: String,
24}
25
26/// Review a plugin using an LLM provider.
27///
28/// Reads plugin files from `plugin_path`, constructs a structured review
29/// prompt, sends it to `provider`, and returns the review report. If
30/// `output_dir` is specified, the report is also saved as a Markdown file.
31///
32/// # Errors
33///
34/// Returns `SoukError::Io` if the required `plugin.json` cannot be read, or
35/// `SoukError::LlmApiError` if the LLM provider call fails.
36pub fn review_plugin(
37    plugin_path: &Path,
38    provider: &dyn LlmProvider,
39    output_dir: Option<&Path>,
40) -> Result<ReviewReport, SoukError> {
41    // 1. Read plugin.json (required)
42    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    // 2. Read extends-plugin.json (optional)
46    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    // 3. Read README.md (optional)
52    let readme_path = plugin_path.join("README.md");
53    let readme = std::fs::read_to_string(&readme_path).ok();
54
55    // 4. Enumerate skills
56    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    // 5. Build the prompt
63    let prompt = build_plugin_review_prompt(
64        &plugin_json,
65        extends_json.as_deref(),
66        readme.as_deref(),
67        &skills_summary,
68    );
69
70    // 6. Send to LLM
71    let review_text = provider.complete(&prompt)?;
72
73    // 7. Build report
74    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    // 8. Save report if output_dir specified
87    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
106/// Build the structured review prompt from plugin content.
107///
108/// This is intentionally kept as a pure function (no I/O) so it can be
109/// unit-tested independently.
110pub 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
162/// Returns the current date as a `YYYY-MM-DD` string.
163///
164/// Uses `std::time::SystemTime` to avoid pulling in the `chrono` crate.
165fn 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    // 86400 seconds per day; epoch is 1970-01-01.
172    let days = secs / 86400;
173
174    // Compute year/month/day from days since epoch (civil calendar).
175    let (year, month, day) = days_to_civil(days as i64);
176    format!("{year:04}-{month:02}-{day:02}")
177}
178
179/// Convert days since Unix epoch to (year, month, day).
180///
181/// Algorithm from Howard Hinnant's `chrono`-compatible date library.
182fn 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// ---------------------------------------------------------------------------
197// Tests
198// ---------------------------------------------------------------------------
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::review::provider::MockProvider;
204    use tempfile::TempDir;
205
206    /// Create a minimal plugin directory with the required plugin.json.
207    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    /// Create a full plugin directory with extends, README, and skills.
220    fn setup_full_plugin(tmp: &TempDir) -> std::path::PathBuf {
221        let plugin = setup_plugin(tmp);
222
223        // extends-plugin.json
224        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        // README.md
231        std::fs::write(
232            plugin.join("README.md"),
233            "# Test Plugin\n\nA plugin for testing.",
234        )
235        .unwrap();
236
237        // Skills
238        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        // Format: YYYY-MM-DD
373        assert_eq!(date.len(), 10);
374        assert_eq!(&date[4..5], "-");
375        assert_eq!(&date[7..8], "-");
376        // Year should be plausible (2020+)
377        let year: i32 = date[..4].parse().unwrap();
378        assert!(year >= 2020);
379    }
380}