Skip to main content

autom8/claude/
review.rs

1//! Code review and correction.
2//!
3//! Handles reviewing completed work and correcting issues.
4
5use std::io::{BufRead, BufReader, Write};
6use std::path::Path;
7use std::process::{Command, Stdio};
8
9use crate::error::{Autom8Error, Result};
10use crate::prompts::{CORRECTOR_PROMPT, REVIEWER_PROMPT};
11use crate::spec::Spec;
12
13use super::stream::{extract_text_from_stream_line, extract_usage_from_result_line};
14use super::types::{ClaudeErrorInfo, ClaudeUsage};
15
16const REVIEW_FILE: &str = "autom8_review.md";
17
18/// Result from running the reviewer.
19#[derive(Debug, Clone)]
20pub struct ReviewResult {
21    pub outcome: ReviewOutcome,
22    /// Token usage data from the Claude API response
23    pub usage: Option<ClaudeUsage>,
24}
25
26#[derive(Debug, Clone, PartialEq)]
27pub enum ReviewOutcome {
28    Pass,
29    IssuesFound,
30    Error(ClaudeErrorInfo),
31}
32
33/// Result from running the corrector.
34#[derive(Debug, Clone)]
35pub struct CorrectorResult {
36    pub outcome: CorrectorOutcome,
37    /// Token usage data from the Claude API response
38    pub usage: Option<ClaudeUsage>,
39}
40
41#[derive(Debug, Clone, PartialEq)]
42pub enum CorrectorOutcome {
43    Complete,
44    Error(ClaudeErrorInfo),
45}
46
47/// Run the reviewer agent to check completed work for quality issues.
48pub fn run_reviewer<F>(
49    spec: &Spec,
50    iteration: u32,
51    max_iterations: u32,
52    mut on_output: F,
53) -> Result<ReviewResult>
54where
55    F: FnMut(&str),
56{
57    let prompt = build_reviewer_prompt(spec, iteration, max_iterations);
58
59    let mut child = Command::new("claude")
60        .args([
61            "--dangerously-skip-permissions",
62            "--print",
63            "--output-format",
64            "stream-json",
65            "--verbose",
66        ])
67        .stdin(Stdio::piped())
68        .stdout(Stdio::piped())
69        .stderr(Stdio::piped())
70        .spawn()
71        .map_err(|e| Autom8Error::ClaudeError(format!("Failed to spawn claude: {}", e)))?;
72
73    if let Some(mut stdin) = child.stdin.take() {
74        stdin
75            .write_all(prompt.as_bytes())
76            .map_err(|e| Autom8Error::ClaudeError(format!("Failed to write to stdin: {}", e)))?;
77    }
78
79    let stderr = child.stderr.take();
80
81    let stdout = child
82        .stdout
83        .take()
84        .ok_or_else(|| Autom8Error::ClaudeError("Failed to capture stdout".into()))?;
85
86    let reader = BufReader::new(stdout);
87    let mut usage: Option<ClaudeUsage> = None;
88
89    for line in reader.lines() {
90        let line = line.map_err(|e| Autom8Error::ClaudeError(format!("Read error: {}", e)))?;
91
92        if let Some(text) = extract_text_from_stream_line(&line) {
93            on_output(&text);
94        }
95
96        // Try to extract usage from result events
97        if let Some(line_usage) = extract_usage_from_result_line(&line) {
98            usage = Some(line_usage);
99        }
100    }
101
102    let status = child
103        .wait()
104        .map_err(|e| Autom8Error::ClaudeError(format!("Wait error: {}", e)))?;
105
106    if !status.success() {
107        let stderr_content = stderr
108            .map(|s| std::io::read_to_string(s).unwrap_or_default())
109            .unwrap_or_default();
110        let error_info = ClaudeErrorInfo::from_process_failure(
111            status,
112            if stderr_content.is_empty() {
113                None
114            } else {
115                Some(stderr_content)
116            },
117        );
118        return Ok(ReviewResult {
119            outcome: ReviewOutcome::Error(error_info),
120            usage,
121        });
122    }
123
124    // Check if autom8_review.md exists and has content
125    let review_path = Path::new(REVIEW_FILE);
126    let outcome = if review_path.exists() {
127        match std::fs::read_to_string(review_path) {
128            Ok(content) if !content.trim().is_empty() => ReviewOutcome::IssuesFound,
129            Ok(_) => ReviewOutcome::Pass,
130            Err(e) => ReviewOutcome::Error(ClaudeErrorInfo::new(format!(
131                "Failed to read review file: {}",
132                e
133            ))),
134        }
135    } else {
136        ReviewOutcome::Pass
137    };
138
139    Ok(ReviewResult { outcome, usage })
140}
141
142/// Run the corrector agent to fix issues identified by the reviewer.
143pub fn run_corrector<F>(spec: &Spec, iteration: u32, mut on_output: F) -> Result<CorrectorResult>
144where
145    F: FnMut(&str),
146{
147    let max_iterations = 3;
148    let prompt = build_corrector_prompt(spec, iteration, max_iterations);
149
150    let mut child = Command::new("claude")
151        .args([
152            "--dangerously-skip-permissions",
153            "--print",
154            "--output-format",
155            "stream-json",
156            "--verbose",
157        ])
158        .stdin(Stdio::piped())
159        .stdout(Stdio::piped())
160        .stderr(Stdio::piped())
161        .spawn()
162        .map_err(|e| Autom8Error::ClaudeError(format!("Failed to spawn claude: {}", e)))?;
163
164    if let Some(mut stdin) = child.stdin.take() {
165        stdin
166            .write_all(prompt.as_bytes())
167            .map_err(|e| Autom8Error::ClaudeError(format!("Failed to write to stdin: {}", e)))?;
168    }
169
170    let stderr = child.stderr.take();
171
172    let stdout = child
173        .stdout
174        .take()
175        .ok_or_else(|| Autom8Error::ClaudeError("Failed to capture stdout".into()))?;
176
177    let reader = BufReader::new(stdout);
178    let mut usage: Option<ClaudeUsage> = None;
179
180    for line in reader.lines() {
181        let line = line.map_err(|e| Autom8Error::ClaudeError(format!("Read error: {}", e)))?;
182
183        if let Some(text) = extract_text_from_stream_line(&line) {
184            on_output(&text);
185        }
186
187        // Try to extract usage from result events
188        if let Some(line_usage) = extract_usage_from_result_line(&line) {
189            usage = Some(line_usage);
190        }
191    }
192
193    let status = child
194        .wait()
195        .map_err(|e| Autom8Error::ClaudeError(format!("Wait error: {}", e)))?;
196
197    if !status.success() {
198        let stderr_content = stderr
199            .map(|s| std::io::read_to_string(s).unwrap_or_default())
200            .unwrap_or_default();
201        let error_info = ClaudeErrorInfo::from_process_failure(
202            status,
203            if stderr_content.is_empty() {
204                None
205            } else {
206                Some(stderr_content)
207            },
208        );
209        return Ok(CorrectorResult {
210            outcome: CorrectorOutcome::Error(error_info),
211            usage,
212        });
213    }
214
215    Ok(CorrectorResult {
216        outcome: CorrectorOutcome::Complete,
217        usage,
218    })
219}
220
221fn build_reviewer_prompt(spec: &Spec, iteration: u32, max_iterations: u32) -> String {
222    let stories_context = spec
223        .user_stories
224        .iter()
225        .map(|s| {
226            let criteria = s
227                .acceptance_criteria
228                .iter()
229                .map(|c| format!("  - {}", c))
230                .collect::<Vec<_>>()
231                .join("\n");
232            format!(
233                "### {}: {}\n{}\n\n**Acceptance Criteria:**\n{}",
234                s.id, s.title, s.description, criteria
235            )
236        })
237        .collect::<Vec<_>>()
238        .join("\n\n");
239
240    REVIEWER_PROMPT
241        .replace("{project}", &spec.project)
242        .replace("{feature_description}", &spec.description)
243        .replace("{stories_context}", &stories_context)
244        .replace("{iteration}", &iteration.to_string())
245        .replace("{max_iterations}", &max_iterations.to_string())
246}
247
248fn build_corrector_prompt(spec: &Spec, iteration: u32, max_iterations: u32) -> String {
249    let stories_context = spec
250        .user_stories
251        .iter()
252        .map(|s| {
253            let criteria = s
254                .acceptance_criteria
255                .iter()
256                .map(|c| format!("  - {}", c))
257                .collect::<Vec<_>>()
258                .join("\n");
259            format!(
260                "### {}: {}\n{}\n\n**Acceptance Criteria:**\n{}",
261                s.id, s.title, s.description, criteria
262            )
263        })
264        .collect::<Vec<_>>()
265        .join("\n\n");
266
267    CORRECTOR_PROMPT
268        .replace("{project}", &spec.project)
269        .replace("{feature_description}", &spec.description)
270        .replace("{stories_context}", &stories_context)
271        .replace("{iteration}", &iteration.to_string())
272        .replace("{max_iterations}", &max_iterations.to_string())
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::spec::UserStory;
279
280    #[test]
281    fn test_review_outcome_variants() {
282        let pass = ReviewOutcome::Pass;
283        let issues = ReviewOutcome::IssuesFound;
284        let error = ReviewOutcome::Error(ClaudeErrorInfo::new("test error"));
285
286        assert_eq!(pass, ReviewOutcome::Pass);
287        assert_eq!(issues, ReviewOutcome::IssuesFound);
288        assert_eq!(
289            error,
290            ReviewOutcome::Error(ClaudeErrorInfo::new("test error"))
291        );
292    }
293
294    #[test]
295    fn test_review_result_with_usage() {
296        let usage = ClaudeUsage {
297            input_tokens: 100,
298            output_tokens: 50,
299            ..Default::default()
300        };
301        let result = ReviewResult {
302            outcome: ReviewOutcome::Pass,
303            usage: Some(usage.clone()),
304        };
305        assert!(matches!(result.outcome, ReviewOutcome::Pass));
306        assert!(result.usage.is_some());
307        assert_eq!(result.usage.unwrap().input_tokens, 100);
308    }
309
310    #[test]
311    fn test_review_result_without_usage() {
312        let result = ReviewResult {
313            outcome: ReviewOutcome::IssuesFound,
314            usage: None,
315        };
316        assert!(matches!(result.outcome, ReviewOutcome::IssuesFound));
317        assert!(result.usage.is_none());
318    }
319
320    #[test]
321    fn test_corrector_outcome_variants() {
322        let complete = CorrectorOutcome::Complete;
323        let error = CorrectorOutcome::Error(ClaudeErrorInfo::new("test error"));
324
325        assert_eq!(complete, CorrectorOutcome::Complete);
326        assert_eq!(
327            error,
328            CorrectorOutcome::Error(ClaudeErrorInfo::new("test error"))
329        );
330    }
331
332    #[test]
333    fn test_corrector_result_with_usage() {
334        let usage = ClaudeUsage {
335            input_tokens: 200,
336            output_tokens: 100,
337            ..Default::default()
338        };
339        let result = CorrectorResult {
340            outcome: CorrectorOutcome::Complete,
341            usage: Some(usage.clone()),
342        };
343        assert!(matches!(result.outcome, CorrectorOutcome::Complete));
344        assert!(result.usage.is_some());
345        assert_eq!(result.usage.unwrap().input_tokens, 200);
346    }
347
348    #[test]
349    fn test_corrector_result_without_usage() {
350        let result = CorrectorResult {
351            outcome: CorrectorOutcome::Complete,
352            usage: None,
353        };
354        assert!(matches!(result.outcome, CorrectorOutcome::Complete));
355        assert!(result.usage.is_none());
356    }
357
358    #[test]
359    fn test_build_reviewer_prompt() {
360        let spec = Spec {
361            project: "TestProject".into(),
362            branch_name: "test-branch".into(),
363            description: "A test feature description".into(),
364            user_stories: vec![UserStory {
365                id: "US-001".into(),
366                title: "First Story".into(),
367                description: "First story description".into(),
368                acceptance_criteria: vec!["Criterion A".into()],
369                priority: 1,
370                passes: true,
371                notes: String::new(),
372            }],
373        };
374
375        let prompt = build_reviewer_prompt(&spec, 1, 3);
376        assert!(prompt.contains("TestProject"));
377        assert!(prompt.contains("Review iteration 1/3"));
378        assert!(prompt.contains("US-001"));
379    }
380}