scud/commands/
test.rs

1//! Test command - Run tests and spawn repair agents until they pass
2//!
3//! This command runs a test/validation command and if it fails, spawns an agent
4//! to fix the issues. It loops until the tests pass or max attempts is reached.
5
6use anyhow::Result;
7use colored::Colorize;
8use std::path::PathBuf;
9use std::thread;
10use std::time::Duration;
11
12use crate::agents::AgentDef;
13use crate::backpressure::{self, BackpressureConfig, ValidationResult};
14use crate::commands::spawn::terminal::{self, Harness};
15use crate::storage::Storage;
16
17/// Run the test command
18#[allow(clippy::too_many_arguments)]
19pub fn run(
20    project_root: Option<PathBuf>,
21    command: Option<&str>,
22    max_attempts: usize,
23    harness_arg: &str,
24    agent_type: &str,
25    session_name: Option<String>,
26    attach: bool,
27) -> Result<()> {
28    let storage = Storage::new(project_root.clone());
29
30    if !storage.is_initialized() {
31        anyhow::bail!("SCUD not initialized. Run: scud init");
32    }
33
34    // Check tmux is available
35    terminal::check_tmux_available()?;
36
37    // Get working directory
38    let working_dir = project_root
39        .clone()
40        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
41
42    // Load backpressure config or use provided command
43    let bp_config = if let Some(cmd) = command {
44        BackpressureConfig {
45            commands: vec![cmd.to_string()],
46            stop_on_failure: true,
47            timeout_secs: 300,
48        }
49    } else {
50        let config = BackpressureConfig::load(project_root.as_ref())?;
51        if config.commands.is_empty() {
52            anyhow::bail!(
53                "No test command provided and no backpressure commands configured.\n\
54                 Use: scud test --command 'your test command'\n\
55                 Or configure: scud config backpressure 'cargo test'"
56            );
57        }
58        config
59    };
60
61    // Parse harness
62    let harness = Harness::parse(harness_arg)?;
63    terminal::find_harness_binary(harness)?;
64
65    // Generate session name
66    let session_name = session_name.unwrap_or_else(|| "scud-test".to_string());
67
68    // Display header
69    println!("{}", "SCUD Test & Fix".cyan().bold());
70    println!("{}", "═".repeat(50));
71    println!(
72        "{:<20} {}",
73        "Commands:".dimmed(),
74        bp_config.commands.join(", ").cyan()
75    );
76    println!(
77        "{:<20} {}",
78        "Max attempts:".dimmed(),
79        max_attempts.to_string().cyan()
80    );
81    println!(
82        "{:<20} {}",
83        "Repair agent:".dimmed(),
84        agent_type.cyan()
85    );
86    println!("{:<20} {}", "Harness:".dimmed(), harness.name().cyan());
87    println!();
88
89    // Main loop
90    for attempt in 1..=max_attempts {
91        println!(
92            "{} {}/{}",
93            "Attempt".blue().bold(),
94            attempt,
95            max_attempts
96        );
97        println!("{}", "-".repeat(40).blue());
98
99        // Run validation
100        println!("  {} Running tests...", "→".dimmed());
101        let result = backpressure::run_validation(&working_dir, &bp_config)?;
102
103        if result.all_passed {
104            println!();
105            println!(
106                "{} All tests passed on attempt {}!",
107                "✓".green().bold(),
108                attempt
109            );
110            return Ok(());
111        }
112
113        // Tests failed - show errors
114        println!();
115        println!("  {} Tests failed:", "✗".red());
116        for failure in &result.failures {
117            println!("    - {}", failure.red());
118        }
119
120        // Get error output for the agent
121        let error_output = format_error_output(&result);
122
123        if attempt == max_attempts {
124            println!();
125            println!(
126                "{} Max attempts ({}) reached. Tests still failing.",
127                "!".red().bold(),
128                max_attempts
129            );
130            return Err(anyhow::anyhow!("Tests failed after {} attempts", max_attempts));
131        }
132
133        // Spawn repair agent
134        println!();
135        println!(
136            "  {} Spawning {} agent to fix...",
137            "→".dimmed(),
138            agent_type
139        );
140
141        let repair_marker = working_dir
142            .join(".scud")
143            .join(format!("test-repair-complete-{}", attempt));
144
145        // Clean up any existing marker
146        let _ = std::fs::remove_file(&repair_marker);
147
148        spawn_repair_agent(
149            &working_dir,
150            &session_name,
151            attempt,
152            harness,
153            agent_type,
154            &bp_config.commands,
155            &error_output,
156            &repair_marker,
157        )?;
158
159        // Wait for repair to complete
160        println!("  {} Waiting for repair agent...", "→".dimmed());
161        wait_for_repair(&repair_marker, attach, &session_name)?;
162
163        println!();
164    }
165
166    Ok(())
167}
168
169/// Format error output from validation result for the repair agent
170fn format_error_output(result: &ValidationResult) -> String {
171    let mut output = String::new();
172
173    for cmd_result in &result.results {
174        if !cmd_result.passed {
175            output.push_str(&format!("Command: {}\n", cmd_result.command));
176            output.push_str(&format!("Exit code: {:?}\n", cmd_result.exit_code));
177            if !cmd_result.stdout.is_empty() {
178                output.push_str(&format!("Stdout:\n{}\n", cmd_result.stdout));
179            }
180            if !cmd_result.stderr.is_empty() {
181                output.push_str(&format!("Stderr:\n{}\n", cmd_result.stderr));
182            }
183            output.push('\n');
184        }
185    }
186
187    output
188}
189
190/// Spawn a repair agent
191#[allow(clippy::too_many_arguments)]
192fn spawn_repair_agent(
193    working_dir: &std::path::Path,
194    session_name: &str,
195    attempt: usize,
196    default_harness: Harness,
197    agent_type: &str,
198    commands: &[String],
199    error_output: &str,
200    repair_marker: &std::path::Path,
201) -> Result<()> {
202    // Try to load agent definition
203    let (harness, model) = match AgentDef::try_load(agent_type, working_dir) {
204        Some(agent_def) => {
205            let h = agent_def.harness().unwrap_or(default_harness);
206            let m = agent_def.model().map(String::from);
207            (h, m)
208        }
209        None => {
210            println!(
211                "    {} Agent '{}' not found, using defaults",
212                "!".yellow(),
213                agent_type
214            );
215            (default_harness, None)
216        }
217    };
218
219    let commands_str = commands.join(" && ");
220    let marker_path = repair_marker.display();
221
222    let prompt = format!(
223        r#"You are a repair agent fixing test/build failures.
224
225## Failed Command(s)
226{commands_str}
227
228## Error Output
229{error_output}
230
231## Your Mission
2321. Analyze the error output to understand what went wrong
2332. Read the relevant source files mentioned in the errors
2343. Fix the issues while preserving the intended functionality
2354. Run the test command to verify your fix: {commands_str}
236
237## Important
238- Focus on fixing the specific errors shown above
239- Don't refactor unrelated code
240- After each fix attempt, re-run the tests to verify
241- Keep trying until the tests pass
242
243## When Done
244When the tests pass, signal completion:
245```bash
246echo "SUCCESS" > {marker_path}
247```
248
249If you cannot fix the issue and need human help:
250```bash
251echo "BLOCKED: <reason>" > {marker_path}
252```
253"#,
254        commands_str = commands_str,
255        error_output = error_output,
256        marker_path = marker_path,
257    );
258
259    let window_name = format!("repair-{}", attempt);
260
261    terminal::spawn_terminal_with_harness_and_model(
262        &window_name,
263        &prompt,
264        working_dir,
265        session_name,
266        harness,
267        model.as_deref(),
268    )?;
269
270    let agent_info = if let Some(ref m) = model {
271        format!("{}:{}", harness.name(), m)
272    } else {
273        harness.name().to_string()
274    };
275
276    println!(
277        "    {} Spawned: {} [{}] {}:{}",
278        "✓".green(),
279        window_name.cyan(),
280        agent_info.dimmed(),
281        session_name.dimmed(),
282        attempt
283    );
284
285    Ok(())
286}
287
288/// Wait for repair to complete by polling for marker file
289fn wait_for_repair(
290    marker_path: &std::path::Path,
291    attach: bool,
292    session_name: &str,
293) -> Result<()> {
294    // If attach mode, tell user to come back when done
295    if attach {
296        println!(
297            "    {} Attaching to session. Mark completion with: echo SUCCESS > {}",
298            "→".dimmed(),
299            marker_path.display()
300        );
301        terminal::tmux_attach(session_name)?;
302
303        // After detach, check if marker exists
304        if marker_path.exists() {
305            let content = std::fs::read_to_string(marker_path)?;
306            std::fs::remove_file(marker_path)?;
307
308            if content.starts_with("BLOCKED") {
309                println!("    {} Agent reported: {}", "!".yellow(), content.trim());
310            }
311        }
312        return Ok(());
313    }
314
315    // Poll for marker file
316    let timeout = Duration::from_secs(3600); // 1 hour timeout
317    let start = std::time::Instant::now();
318    let poll_interval = Duration::from_secs(5);
319
320    println!(
321        "    {} Attach with: tmux attach -t {}",
322        "ℹ".dimmed(),
323        session_name
324    );
325
326    loop {
327        if start.elapsed() > timeout {
328            println!(
329                "    {} Repair timed out after 1 hour",
330                "!".yellow()
331            );
332            return Ok(());
333        }
334
335        if marker_path.exists() {
336            let content = std::fs::read_to_string(marker_path)?;
337            std::fs::remove_file(marker_path)?;
338
339            if content.starts_with("SUCCESS") {
340                println!("    {} Repair agent completed", "✓".green());
341            } else if content.starts_with("BLOCKED") {
342                println!(
343                    "    {} Agent reported blocked: {}",
344                    "!".yellow(),
345                    content.trim()
346                );
347                // Continue to next attempt anyway - maybe it partially fixed things
348            } else {
349                println!("    {} Repair agent finished", "✓".green());
350            }
351
352            return Ok(());
353        }
354
355        thread::sleep(poll_interval);
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn test_format_error_output() {
365        let result = ValidationResult {
366            all_passed: false,
367            failures: vec!["cargo test".to_string()],
368            results: vec![backpressure::CommandResult {
369                command: "cargo test".to_string(),
370                passed: false,
371                exit_code: Some(1),
372                stdout: "running 2 tests".to_string(),
373                stderr: "error: test failed".to_string(),
374                duration_secs: 1.5,
375            }],
376        };
377
378        let output = format_error_output(&result);
379        assert!(output.contains("cargo test"));
380        assert!(output.contains("Exit code: Some(1)"));
381        assert!(output.contains("test failed"));
382    }
383}