scud/
backpressure.rs

1//! Backpressure validation for maintaining code quality during automated execution.
2//!
3//! # Overview
4//!
5//! Backpressure is a quality gate mechanism that runs programmatic validation
6//! after each wave of task execution. It prevents bad code from accumulating by
7//! catching issues early - if validation fails, the affected tasks are marked as
8//! `Failed` so they can be re-attempted or debugged.
9//!
10//! ## What Backpressure Validates
11//!
12//! - **Build/compile checks** - Ensures code compiles successfully
13//! - **Linting** - Catches style issues and common mistakes
14//! - **Type checking** - Validates type correctness (for typed languages)
15//! - **Tests** - Runs the test suite to catch regressions
16//!
17//! ## Workflow Integration
18//!
19//! In swarm mode, backpressure runs after each wave completes:
20//!
21//! ```text
22//! Wave 1: [Task A, Task B] -> Backpressure Check -> Pass? -> Wave 2
23//!                                    |
24//!                                    v
25//!                             Fail? -> Mark tasks as Failed
26//! ```
27//!
28//! This creates a feedback loop where AI agents can see which tasks caused
29//! validation failures and attempt repairs.
30//!
31//! # Configuration
32//!
33//! Backpressure commands are configured in `.scud/config.toml`:
34//!
35//! ```toml
36//! [swarm.backpressure]
37//! commands = ["cargo build", "cargo test", "cargo clippy -- -D warnings"]
38//! stop_on_failure = true  # Stop at first failure (default: true)
39//! timeout_secs = 300      # Per-command timeout (default: 300 = 5 minutes)
40//! ```
41//!
42//! If no configuration is found, backpressure auto-detects commands based on
43//! project type (Rust, Node.js, Python, Go).
44//!
45//! # Example
46//!
47//! ```no_run
48//! use std::path::Path;
49//! use scud::backpressure::{BackpressureConfig, run_validation};
50//!
51//! // Load configuration (auto-detects if not configured)
52//! let config = BackpressureConfig::load(None).expect("Failed to load config");
53//!
54//! // Or create a custom configuration
55//! let custom_config = BackpressureConfig {
56//!     commands: vec![
57//!         "cargo build".to_string(),
58//!         "cargo test".to_string(),
59//!     ],
60//!     stop_on_failure: true,
61//!     timeout_secs: 300,
62//! };
63//!
64//! // Run validation in a working directory
65//! let working_dir = Path::new(".");
66//! let result = run_validation(working_dir, &custom_config).expect("Validation failed");
67//!
68//! if result.all_passed {
69//!     println!("All checks passed!");
70//! } else {
71//!     println!("Failures: {:?}", result.failures);
72//!     for cmd_result in &result.results {
73//!         if !cmd_result.passed {
74//!             println!("  {} failed with code {:?}", cmd_result.command, cmd_result.exit_code);
75//!             println!("  stderr: {}", cmd_result.stderr);
76//!         }
77//!     }
78//! }
79//! ```
80//!
81//! # Auto-Detection
82//!
83//! When no explicit configuration exists, backpressure detects project type:
84//!
85//! | Project Type | Detected By | Default Commands |
86//! |--------------|-------------|------------------|
87//! | Rust | `Cargo.toml` | `cargo build`, `cargo test` |
88//! | Node.js | `package.json` | Scripts: `build`, `test`, `lint`, `typecheck` |
89//! | Python | `pyproject.toml` or `setup.py` | `pytest` |
90//! | Go | `go.mod` | `go build ./...`, `go test ./...` |
91
92use anyhow::Result;
93use serde::{Deserialize, Serialize};
94use std::path::{Path, PathBuf};
95use std::process::Command;
96
97/// Backpressure configuration
98#[derive(Debug, Clone, Serialize, Deserialize, Default)]
99pub struct BackpressureConfig {
100    /// Commands to run for validation (in order)
101    pub commands: Vec<String>,
102    /// Whether to stop on first failure
103    #[serde(default = "default_stop_on_failure")]
104    pub stop_on_failure: bool,
105    /// Timeout per command in seconds
106    #[serde(default = "default_timeout")]
107    pub timeout_secs: u64,
108}
109
110fn default_stop_on_failure() -> bool {
111    true
112}
113
114fn default_timeout() -> u64 {
115    300 // 5 minutes
116}
117
118impl BackpressureConfig {
119    /// Load backpressure config from project
120    pub fn load(project_root: Option<&PathBuf>) -> Result<Self> {
121        let root = project_root
122            .cloned()
123            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
124
125        let config_path = root.join(".scud").join("config.toml");
126
127        if !config_path.exists() {
128            // Try to auto-detect based on project type
129            return Ok(Self::auto_detect(&root));
130        }
131
132        let content = std::fs::read_to_string(&config_path)?;
133        let config: toml::Value = toml::from_str(&content)?;
134
135        // Look for [swarm.backpressure] section
136        if let Some(swarm) = config.get("swarm") {
137            if let Some(bp) = swarm.get("backpressure") {
138                let bp_config: BackpressureConfig = bp.clone().try_into()?;
139                return Ok(bp_config);
140            }
141        }
142
143        // Fallback to auto-detection
144        Ok(Self::auto_detect(&root))
145    }
146
147    /// Auto-detect backpressure commands based on project type
148    fn auto_detect(root: &Path) -> Self {
149        let mut commands = Vec::new();
150
151        // Rust project
152        if root.join("Cargo.toml").exists() {
153            commands.push("cargo build".to_string());
154            commands.push("cargo test".to_string());
155        }
156
157        // Node.js project
158        if root.join("package.json").exists() {
159            // Check for common scripts
160            if let Ok(content) = std::fs::read_to_string(root.join("package.json")) {
161                if content.contains("\"build\"") {
162                    commands.push("npm run build".to_string());
163                }
164                if content.contains("\"test\"") {
165                    commands.push("npm test".to_string());
166                }
167                if content.contains("\"lint\"") {
168                    commands.push("npm run lint".to_string());
169                }
170                if content.contains("\"typecheck\"") {
171                    commands.push("npm run typecheck".to_string());
172                }
173            }
174        }
175
176        // Python project
177        if (root.join("pyproject.toml").exists() || root.join("setup.py").exists())
178            && (root.join("pytest.ini").exists() || root.join("pyproject.toml").exists())
179        {
180            commands.push("pytest".to_string());
181        }
182
183        // Go project
184        if root.join("go.mod").exists() {
185            commands.push("go build ./...".to_string());
186            commands.push("go test ./...".to_string());
187        }
188
189        Self {
190            commands,
191            stop_on_failure: true,
192            timeout_secs: 300,
193        }
194    }
195}
196
197/// Result of running validation
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct ValidationResult {
200    /// Whether all checks passed
201    pub all_passed: bool,
202    /// List of failures (command names that failed)
203    pub failures: Vec<String>,
204    /// Detailed results per command
205    pub results: Vec<CommandResult>,
206}
207
208/// Result of a single command
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct CommandResult {
211    /// Command that was run
212    pub command: String,
213    /// Whether it passed
214    pub passed: bool,
215    /// Exit code
216    pub exit_code: Option<i32>,
217    /// Stdout (truncated)
218    pub stdout: String,
219    /// Stderr (truncated)
220    pub stderr: String,
221    /// Duration in seconds
222    pub duration_secs: f64,
223}
224
225/// Run backpressure validation
226pub fn run_validation(working_dir: &Path, config: &BackpressureConfig) -> Result<ValidationResult> {
227    let mut results = Vec::new();
228    let mut failures = Vec::new();
229    let mut all_passed = true;
230
231    for cmd_str in &config.commands {
232        println!("      Running: {}", cmd_str);
233
234        let start = std::time::Instant::now();
235        let result = run_command(working_dir, cmd_str, config.timeout_secs);
236        let duration = start.elapsed().as_secs_f64();
237
238        match result {
239            Ok((exit_code, stdout, stderr)) => {
240                let passed = exit_code == 0;
241                if !passed {
242                    all_passed = false;
243                    failures.push(cmd_str.clone());
244                }
245
246                results.push(CommandResult {
247                    command: cmd_str.clone(),
248                    passed,
249                    exit_code: Some(exit_code),
250                    stdout: truncate_output(&stdout, 1000),
251                    stderr: truncate_output(&stderr, 1000),
252                    duration_secs: duration,
253                });
254
255                if !passed && config.stop_on_failure {
256                    break;
257                }
258            }
259            Err(e) => {
260                all_passed = false;
261                failures.push(format!("{} (error: {})", cmd_str, e));
262
263                results.push(CommandResult {
264                    command: cmd_str.clone(),
265                    passed: false,
266                    exit_code: None,
267                    stdout: String::new(),
268                    stderr: e.to_string(),
269                    duration_secs: duration,
270                });
271
272                if config.stop_on_failure {
273                    break;
274                }
275            }
276        }
277    }
278
279    Ok(ValidationResult {
280        all_passed,
281        failures,
282        results,
283    })
284}
285
286/// Run a single command using sh -c for proper shell execution with timeout
287fn run_command(
288    working_dir: &Path,
289    cmd_str: &str,
290    timeout_secs: u64,
291) -> Result<(i32, String, String)> {
292    use std::io::Read;
293    use std::process::Stdio;
294    use std::time::{Duration, Instant};
295
296    if cmd_str.trim().is_empty() {
297        anyhow::bail!("Empty command");
298    }
299
300    // Use sh -c to properly handle complex commands with pipes, redirections, etc.
301    let mut child = Command::new("sh")
302        .arg("-c")
303        .arg(cmd_str)
304        .current_dir(working_dir)
305        .stdout(Stdio::piped())
306        .stderr(Stdio::piped())
307        .spawn()?;
308
309    let timeout = Duration::from_secs(timeout_secs);
310    let start = Instant::now();
311    let poll_interval = Duration::from_millis(100);
312
313    // Poll for completion with timeout
314    loop {
315        match child.try_wait()? {
316            Some(status) => {
317                // Process completed - read output
318                let mut stdout = String::new();
319                let mut stderr = String::new();
320
321                if let Some(mut stdout_pipe) = child.stdout.take() {
322                    let _ = stdout_pipe.read_to_string(&mut stdout);
323                }
324                if let Some(mut stderr_pipe) = child.stderr.take() {
325                    let _ = stderr_pipe.read_to_string(&mut stderr);
326                }
327
328                let exit_code = status.code().unwrap_or(-1);
329                return Ok((exit_code, stdout, stderr));
330            }
331            None => {
332                // Process still running - check timeout
333                if start.elapsed() > timeout {
334                    // Kill the process
335                    let _ = child.kill();
336                    let _ = child.wait(); // Reap the zombie
337                    anyhow::bail!(
338                        "Command timed out after {} seconds: {}",
339                        timeout_secs,
340                        cmd_str
341                    );
342                }
343                std::thread::sleep(poll_interval);
344            }
345        }
346    }
347}
348
349/// Truncate output to max length
350fn truncate_output(output: &str, max_len: usize) -> String {
351    if output.len() <= max_len {
352        output.to_string()
353    } else {
354        format!("{}...[truncated]", &output[..max_len])
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use tempfile::TempDir;
362
363    #[test]
364    fn test_auto_detect_rust() {
365        let tmp = TempDir::new().unwrap();
366        std::fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
367
368        let config = BackpressureConfig::auto_detect(tmp.path());
369        assert!(config.commands.contains(&"cargo build".to_string()));
370        assert!(config.commands.contains(&"cargo test".to_string()));
371    }
372
373    #[test]
374    fn test_auto_detect_empty() {
375        let tmp = TempDir::new().unwrap();
376        let config = BackpressureConfig::auto_detect(tmp.path());
377        assert!(config.commands.is_empty());
378    }
379
380    #[test]
381    fn test_truncate_output() {
382        assert_eq!(truncate_output("short", 100), "short");
383
384        let long = "a".repeat(200);
385        let truncated = truncate_output(&long, 50);
386        assert!(truncated.contains("truncated"));
387        assert!(truncated.len() < 200);
388    }
389
390    #[test]
391    fn test_run_command_simple() {
392        let tmp = TempDir::new().unwrap();
393        let result = run_command(tmp.path(), "echo hello", 60);
394        assert!(result.is_ok());
395        let (exit_code, stdout, _stderr) = result.unwrap();
396        assert_eq!(exit_code, 0);
397        assert!(stdout.contains("hello"));
398    }
399
400    #[test]
401    fn test_run_command_with_quotes() {
402        let tmp = TempDir::new().unwrap();
403        let result = run_command(tmp.path(), "echo 'hello world'", 60);
404        assert!(result.is_ok());
405        let (exit_code, stdout, _stderr) = result.unwrap();
406        assert_eq!(exit_code, 0);
407        assert!(stdout.contains("hello world"));
408    }
409
410    #[test]
411    fn test_run_command_with_pipe() {
412        let tmp = TempDir::new().unwrap();
413        let result = run_command(tmp.path(), "echo hello | cat", 60);
414        assert!(result.is_ok());
415        let (exit_code, stdout, _stderr) = result.unwrap();
416        assert_eq!(exit_code, 0);
417        assert!(stdout.contains("hello"));
418    }
419
420    #[test]
421    fn test_run_command_empty() {
422        let tmp = TempDir::new().unwrap();
423        let result = run_command(tmp.path(), "", 60);
424        assert!(result.is_err());
425    }
426
427    #[test]
428    fn test_run_command_whitespace_only() {
429        let tmp = TempDir::new().unwrap();
430        let result = run_command(tmp.path(), "   ", 60);
431        assert!(result.is_err());
432    }
433
434    #[test]
435    fn test_run_command_timeout() {
436        let tmp = TempDir::new().unwrap();
437        let result = run_command(tmp.path(), "sleep 5", 1);
438        assert!(result.is_err());
439        let error_msg = result.unwrap_err().to_string();
440        assert!(error_msg.contains("timed out"));
441    }
442}