1use 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#[derive(Debug, Clone)]
20pub struct ReviewResult {
21 pub outcome: ReviewOutcome,
22 pub usage: Option<ClaudeUsage>,
24}
25
26#[derive(Debug, Clone, PartialEq)]
27pub enum ReviewOutcome {
28 Pass,
29 IssuesFound,
30 Error(ClaudeErrorInfo),
31}
32
33#[derive(Debug, Clone)]
35pub struct CorrectorResult {
36 pub outcome: CorrectorOutcome,
37 pub usage: Option<ClaudeUsage>,
39}
40
41#[derive(Debug, Clone, PartialEq)]
42pub enum CorrectorOutcome {
43 Complete,
44 Error(ClaudeErrorInfo),
45}
46
47pub 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 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 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
142pub 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 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}