use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use std::process::{Command, Stdio};
use crate::error::{Autom8Error, Result};
use crate::prompts::{CORRECTOR_PROMPT, REVIEWER_PROMPT};
use crate::spec::Spec;
use super::stream::{extract_text_from_stream_line, extract_usage_from_result_line};
use super::types::{ClaudeErrorInfo, ClaudeUsage};
const REVIEW_FILE: &str = "autom8_review.md";
#[derive(Debug, Clone)]
pub struct ReviewResult {
pub outcome: ReviewOutcome,
pub usage: Option<ClaudeUsage>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ReviewOutcome {
Pass,
IssuesFound,
Error(ClaudeErrorInfo),
}
#[derive(Debug, Clone)]
pub struct CorrectorResult {
pub outcome: CorrectorOutcome,
pub usage: Option<ClaudeUsage>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum CorrectorOutcome {
Complete,
Error(ClaudeErrorInfo),
}
pub fn run_reviewer<F>(
spec: &Spec,
iteration: u32,
max_iterations: u32,
mut on_output: F,
) -> Result<ReviewResult>
where
F: FnMut(&str),
{
let prompt = build_reviewer_prompt(spec, iteration, max_iterations);
let mut child = Command::new("claude")
.args([
"--dangerously-skip-permissions",
"--print",
"--output-format",
"stream-json",
"--verbose",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| Autom8Error::ClaudeError(format!("Failed to spawn claude: {}", e)))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(prompt.as_bytes())
.map_err(|e| Autom8Error::ClaudeError(format!("Failed to write to stdin: {}", e)))?;
}
let stderr = child.stderr.take();
let stdout = child
.stdout
.take()
.ok_or_else(|| Autom8Error::ClaudeError("Failed to capture stdout".into()))?;
let reader = BufReader::new(stdout);
let mut usage: Option<ClaudeUsage> = None;
for line in reader.lines() {
let line = line.map_err(|e| Autom8Error::ClaudeError(format!("Read error: {}", e)))?;
if let Some(text) = extract_text_from_stream_line(&line) {
on_output(&text);
}
if let Some(line_usage) = extract_usage_from_result_line(&line) {
usage = Some(line_usage);
}
}
let status = child
.wait()
.map_err(|e| Autom8Error::ClaudeError(format!("Wait error: {}", e)))?;
if !status.success() {
let stderr_content = stderr
.map(|s| std::io::read_to_string(s).unwrap_or_default())
.unwrap_or_default();
let error_info = ClaudeErrorInfo::from_process_failure(
status,
if stderr_content.is_empty() {
None
} else {
Some(stderr_content)
},
);
return Ok(ReviewResult {
outcome: ReviewOutcome::Error(error_info),
usage,
});
}
let review_path = Path::new(REVIEW_FILE);
let outcome = if review_path.exists() {
match std::fs::read_to_string(review_path) {
Ok(content) if !content.trim().is_empty() => ReviewOutcome::IssuesFound,
Ok(_) => ReviewOutcome::Pass,
Err(e) => ReviewOutcome::Error(ClaudeErrorInfo::new(format!(
"Failed to read review file: {}",
e
))),
}
} else {
ReviewOutcome::Pass
};
Ok(ReviewResult { outcome, usage })
}
pub fn run_corrector<F>(spec: &Spec, iteration: u32, mut on_output: F) -> Result<CorrectorResult>
where
F: FnMut(&str),
{
let max_iterations = 3;
let prompt = build_corrector_prompt(spec, iteration, max_iterations);
let mut child = Command::new("claude")
.args([
"--dangerously-skip-permissions",
"--print",
"--output-format",
"stream-json",
"--verbose",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| Autom8Error::ClaudeError(format!("Failed to spawn claude: {}", e)))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(prompt.as_bytes())
.map_err(|e| Autom8Error::ClaudeError(format!("Failed to write to stdin: {}", e)))?;
}
let stderr = child.stderr.take();
let stdout = child
.stdout
.take()
.ok_or_else(|| Autom8Error::ClaudeError("Failed to capture stdout".into()))?;
let reader = BufReader::new(stdout);
let mut usage: Option<ClaudeUsage> = None;
for line in reader.lines() {
let line = line.map_err(|e| Autom8Error::ClaudeError(format!("Read error: {}", e)))?;
if let Some(text) = extract_text_from_stream_line(&line) {
on_output(&text);
}
if let Some(line_usage) = extract_usage_from_result_line(&line) {
usage = Some(line_usage);
}
}
let status = child
.wait()
.map_err(|e| Autom8Error::ClaudeError(format!("Wait error: {}", e)))?;
if !status.success() {
let stderr_content = stderr
.map(|s| std::io::read_to_string(s).unwrap_or_default())
.unwrap_or_default();
let error_info = ClaudeErrorInfo::from_process_failure(
status,
if stderr_content.is_empty() {
None
} else {
Some(stderr_content)
},
);
return Ok(CorrectorResult {
outcome: CorrectorOutcome::Error(error_info),
usage,
});
}
Ok(CorrectorResult {
outcome: CorrectorOutcome::Complete,
usage,
})
}
fn build_reviewer_prompt(spec: &Spec, iteration: u32, max_iterations: u32) -> String {
let stories_context = spec
.user_stories
.iter()
.map(|s| {
let criteria = s
.acceptance_criteria
.iter()
.map(|c| format!(" - {}", c))
.collect::<Vec<_>>()
.join("\n");
format!(
"### {}: {}\n{}\n\n**Acceptance Criteria:**\n{}",
s.id, s.title, s.description, criteria
)
})
.collect::<Vec<_>>()
.join("\n\n");
REVIEWER_PROMPT
.replace("{project}", &spec.project)
.replace("{feature_description}", &spec.description)
.replace("{stories_context}", &stories_context)
.replace("{iteration}", &iteration.to_string())
.replace("{max_iterations}", &max_iterations.to_string())
}
fn build_corrector_prompt(spec: &Spec, iteration: u32, max_iterations: u32) -> String {
let stories_context = spec
.user_stories
.iter()
.map(|s| {
let criteria = s
.acceptance_criteria
.iter()
.map(|c| format!(" - {}", c))
.collect::<Vec<_>>()
.join("\n");
format!(
"### {}: {}\n{}\n\n**Acceptance Criteria:**\n{}",
s.id, s.title, s.description, criteria
)
})
.collect::<Vec<_>>()
.join("\n\n");
CORRECTOR_PROMPT
.replace("{project}", &spec.project)
.replace("{feature_description}", &spec.description)
.replace("{stories_context}", &stories_context)
.replace("{iteration}", &iteration.to_string())
.replace("{max_iterations}", &max_iterations.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::spec::UserStory;
#[test]
fn test_review_outcome_variants() {
let pass = ReviewOutcome::Pass;
let issues = ReviewOutcome::IssuesFound;
let error = ReviewOutcome::Error(ClaudeErrorInfo::new("test error"));
assert_eq!(pass, ReviewOutcome::Pass);
assert_eq!(issues, ReviewOutcome::IssuesFound);
assert_eq!(
error,
ReviewOutcome::Error(ClaudeErrorInfo::new("test error"))
);
}
#[test]
fn test_review_result_with_usage() {
let usage = ClaudeUsage {
input_tokens: 100,
output_tokens: 50,
..Default::default()
};
let result = ReviewResult {
outcome: ReviewOutcome::Pass,
usage: Some(usage.clone()),
};
assert!(matches!(result.outcome, ReviewOutcome::Pass));
assert!(result.usage.is_some());
assert_eq!(result.usage.unwrap().input_tokens, 100);
}
#[test]
fn test_review_result_without_usage() {
let result = ReviewResult {
outcome: ReviewOutcome::IssuesFound,
usage: None,
};
assert!(matches!(result.outcome, ReviewOutcome::IssuesFound));
assert!(result.usage.is_none());
}
#[test]
fn test_corrector_outcome_variants() {
let complete = CorrectorOutcome::Complete;
let error = CorrectorOutcome::Error(ClaudeErrorInfo::new("test error"));
assert_eq!(complete, CorrectorOutcome::Complete);
assert_eq!(
error,
CorrectorOutcome::Error(ClaudeErrorInfo::new("test error"))
);
}
#[test]
fn test_corrector_result_with_usage() {
let usage = ClaudeUsage {
input_tokens: 200,
output_tokens: 100,
..Default::default()
};
let result = CorrectorResult {
outcome: CorrectorOutcome::Complete,
usage: Some(usage.clone()),
};
assert!(matches!(result.outcome, CorrectorOutcome::Complete));
assert!(result.usage.is_some());
assert_eq!(result.usage.unwrap().input_tokens, 200);
}
#[test]
fn test_corrector_result_without_usage() {
let result = CorrectorResult {
outcome: CorrectorOutcome::Complete,
usage: None,
};
assert!(matches!(result.outcome, CorrectorOutcome::Complete));
assert!(result.usage.is_none());
}
#[test]
fn test_build_reviewer_prompt() {
let spec = Spec {
project: "TestProject".into(),
branch_name: "test-branch".into(),
description: "A test feature description".into(),
user_stories: vec![UserStory {
id: "US-001".into(),
title: "First Story".into(),
description: "First story description".into(),
acceptance_criteria: vec!["Criterion A".into()],
priority: 1,
passes: true,
notes: String::new(),
}],
};
let prompt = build_reviewer_prompt(&spec, 1, 3);
assert!(prompt.contains("TestProject"));
assert!(prompt.contains("Review iteration 1/3"));
assert!(prompt.contains("US-001"));
}
}