Skip to main content

autom8/claude/
spec.rs

1//! Spec generation from markdown.
2//!
3//! Converts markdown spec files to JSON format using Claude.
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::{SPEC_JSON_CORRECTION_PROMPT, SPEC_JSON_PROMPT};
11use crate::spec::Spec;
12
13use super::stream::{extract_text_from_stream_line, extract_usage_from_result_line};
14use super::types::{ClaudeErrorInfo, ClaudeUsage};
15use super::utils::{extract_json, fix_json_syntax, truncate_json_preview};
16
17const MAX_JSON_RETRY_ATTEMPTS: u32 = 3;
18
19/// Result from spec generation.
20#[derive(Debug, Clone)]
21pub struct SpecGenerationResult {
22    pub spec: Spec,
23    /// Token usage data accumulated from all Claude API calls
24    pub usage: Option<ClaudeUsage>,
25}
26
27/// Internal result from a single Claude call for spec generation.
28struct ClaudeCallResult {
29    output: String,
30    usage: Option<ClaudeUsage>,
31}
32
33/// Run Claude to convert a spec-<feature>.md markdown file into spec-<feature>.json
34/// Implements retry logic (up to 3 attempts) when JSON parsing fails.
35pub fn run_for_spec_generation<F>(
36    spec_content: &str,
37    output_path: &Path,
38    mut on_output: F,
39) -> Result<SpecGenerationResult>
40where
41    F: FnMut(&str),
42{
43    let mut total_usage: Option<ClaudeUsage> = None;
44
45    // Helper to accumulate usage
46    let mut accumulate_usage = |call_usage: Option<ClaudeUsage>| {
47        if let Some(usage) = call_usage {
48            match &mut total_usage {
49                Some(existing) => existing.add(&usage),
50                None => total_usage = Some(usage),
51            }
52        }
53    };
54
55    // First attempt with the initial prompt
56    let initial_prompt = SPEC_JSON_PROMPT.replace("{spec_content}", spec_content);
57    let call_result = run_claude_with_prompt(&initial_prompt, &mut on_output)?;
58    let mut full_output = call_result.output;
59    accumulate_usage(call_result.usage);
60
61    // Try to get JSON either from response or from file if Claude wrote it directly
62    let mut json_str = if let Some(json) = extract_json(&full_output) {
63        json
64    } else if output_path.exists() {
65        // Claude may have written the file directly using tools
66        std::fs::read_to_string(output_path).map_err(|e| {
67            Autom8Error::InvalidGeneratedSpec(format!("Failed to read generated file: {}", e))
68        })?
69    } else {
70        let preview = if full_output.len() > 200 {
71            format!("{}...", &full_output[..200])
72        } else {
73            full_output.clone()
74        };
75        return Err(Autom8Error::InvalidGeneratedSpec(format!(
76            "No valid JSON found in response. Response preview: {:?}",
77            preview
78        )));
79    };
80
81    // Try to parse the JSON, with retry logic on failure
82    let mut last_error: Option<serde_json::Error> = None;
83
84    for attempt in 1..=MAX_JSON_RETRY_ATTEMPTS {
85        match serde_json::from_str::<Spec>(&json_str) {
86            Ok(spec) => {
87                spec.save(output_path)?;
88                return Ok(SpecGenerationResult {
89                    spec,
90                    usage: total_usage,
91                });
92            }
93            Err(e) => {
94                last_error = Some(e);
95
96                if attempt == MAX_JSON_RETRY_ATTEMPTS {
97                    break;
98                }
99
100                let retry_msg = format!(
101                    "\nJSON malformed, retrying (attempt {}/{})...\n",
102                    attempt + 1,
103                    MAX_JSON_RETRY_ATTEMPTS
104                );
105                on_output(&retry_msg);
106
107                let correction_prompt = SPEC_JSON_CORRECTION_PROMPT
108                    .replace("{spec_content}", spec_content)
109                    .replace("{malformed_json}", &json_str)
110                    .replace("{error_message}", &last_error.as_ref().unwrap().to_string())
111                    .replace("{attempt}", &(attempt + 1).to_string())
112                    .replace("{max_attempts}", &MAX_JSON_RETRY_ATTEMPTS.to_string());
113
114                let call_result = run_claude_with_prompt(&correction_prompt, &mut on_output)?;
115                full_output = call_result.output;
116                accumulate_usage(call_result.usage);
117
118                if let Some(json) = extract_json(&full_output) {
119                    json_str = json;
120                } else {
121                    json_str = full_output.clone();
122                }
123            }
124        }
125    }
126
127    // All agentic retries exhausted - try non-agentic fix as final fallback
128    on_output("\nAttempting programmatic JSON fix...\n");
129
130    let fixed_json = fix_json_syntax(&json_str);
131
132    match serde_json::from_str::<Spec>(&fixed_json) {
133        Ok(spec) => {
134            on_output("Programmatic fix succeeded!\n");
135            spec.save(output_path)?;
136            Ok(SpecGenerationResult {
137                spec,
138                usage: total_usage,
139            })
140        }
141        Err(fallback_err) => {
142            let agentic_error = last_error
143                .map(|e| e.to_string())
144                .unwrap_or_else(|| "Unknown error".to_string());
145            let fallback_error = fallback_err.to_string();
146
147            let json_preview = truncate_json_preview(&json_str, 500);
148
149            Err(Autom8Error::InvalidGeneratedSpec(format!(
150                "JSON generation failed after {} agentic attempts and programmatic fallback.\n\n\
151                 Agent error: {}\n\n\
152                 Fallback error: {}\n\n\
153                 Malformed JSON preview:\n{}",
154                MAX_JSON_RETRY_ATTEMPTS, agentic_error, fallback_error, json_preview
155            )))
156        }
157    }
158}
159
160/// Helper function to run Claude with a given prompt and return the raw output and usage.
161fn run_claude_with_prompt<F>(prompt: &str, mut on_output: F) -> Result<ClaudeCallResult>
162where
163    F: FnMut(&str),
164{
165    let mut child = Command::new("claude")
166        .args([
167            "--dangerously-skip-permissions",
168            "--print",
169            "--output-format",
170            "stream-json",
171            "--verbose",
172        ])
173        .stdin(Stdio::piped())
174        .stdout(Stdio::piped())
175        .stderr(Stdio::piped())
176        .spawn()
177        .map_err(|e| Autom8Error::ClaudeError(format!("Failed to spawn claude: {}", e)))?;
178
179    if let Some(mut stdin) = child.stdin.take() {
180        stdin
181            .write_all(prompt.as_bytes())
182            .map_err(|e| Autom8Error::ClaudeError(format!("Failed to write to stdin: {}", e)))?;
183    }
184
185    let stderr = child.stderr.take();
186
187    let stdout = child
188        .stdout
189        .take()
190        .ok_or_else(|| Autom8Error::ClaudeError("Failed to capture stdout".into()))?;
191
192    let reader = BufReader::new(stdout);
193    let mut full_output = String::new();
194    let mut usage: Option<ClaudeUsage> = None;
195
196    for line in reader.lines() {
197        let line = line.map_err(|e| Autom8Error::ClaudeError(format!("Read error: {}", e)))?;
198
199        if let Some(text) = extract_text_from_stream_line(&line) {
200            on_output(&text);
201            full_output.push_str(&text);
202        }
203
204        // Try to extract usage from result events
205        if let Some(line_usage) = extract_usage_from_result_line(&line) {
206            usage = Some(line_usage);
207        }
208    }
209
210    let status = child
211        .wait()
212        .map_err(|e| Autom8Error::ClaudeError(format!("Wait error: {}", e)))?;
213
214    if !status.success() {
215        let stderr_content = stderr
216            .map(|s| std::io::read_to_string(s).unwrap_or_default())
217            .unwrap_or_default();
218        let error_info = ClaudeErrorInfo::from_process_failure(
219            status,
220            if stderr_content.is_empty() {
221                None
222            } else {
223                Some(stderr_content)
224            },
225        );
226        return Err(Autom8Error::SpecGenerationFailed(error_info.message));
227    }
228
229    Ok(ClaudeCallResult {
230        output: full_output,
231        usage,
232    })
233}