scud/commands/swarm/
backpressure.rs

1//! Backpressure configuration and execution
2//!
3//! Backpressure is the programmatic validation that prevents bad code from
4//! being committed. This includes:
5//! - Build/compile checks
6//! - Linting
7//! - Type checking
8//! - Tests
9//!
10//! Configuration is stored in `.scud/config.toml`:
11//! ```toml
12//! [swarm.backpressure]
13//! commands = ["cargo build", "cargo test", "cargo clippy"]
14//! ```
15
16use anyhow::Result;
17use serde::{Deserialize, Serialize};
18use std::path::{Path, PathBuf};
19use std::process::Command;
20
21/// Backpressure configuration
22#[derive(Debug, Clone, Serialize, Deserialize, Default)]
23pub struct BackpressureConfig {
24    /// Commands to run for validation (in order)
25    pub commands: Vec<String>,
26    /// Whether to stop on first failure
27    #[serde(default = "default_stop_on_failure")]
28    pub stop_on_failure: bool,
29    /// Timeout per command in seconds
30    #[serde(default = "default_timeout")]
31    pub timeout_secs: u64,
32}
33
34fn default_stop_on_failure() -> bool {
35    true
36}
37
38fn default_timeout() -> u64 {
39    300 // 5 minutes
40}
41
42impl BackpressureConfig {
43    /// Load backpressure config from project
44    pub fn load(project_root: Option<&PathBuf>) -> Result<Self> {
45        let root = project_root
46            .cloned()
47            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
48
49        let config_path = root.join(".scud").join("config.toml");
50
51        if !config_path.exists() {
52            // Try to auto-detect based on project type
53            return Ok(Self::auto_detect(&root));
54        }
55
56        let content = std::fs::read_to_string(&config_path)?;
57        let config: toml::Value = toml::from_str(&content)?;
58
59        // Look for [swarm.backpressure] section
60        if let Some(swarm) = config.get("swarm") {
61            if let Some(bp) = swarm.get("backpressure") {
62                let bp_config: BackpressureConfig = bp.clone().try_into()?;
63                return Ok(bp_config);
64            }
65        }
66
67        // Fallback to auto-detection
68        Ok(Self::auto_detect(&root))
69    }
70
71    /// Auto-detect backpressure commands based on project type
72    fn auto_detect(root: &Path) -> Self {
73        let mut commands = Vec::new();
74
75        // Rust project
76        if root.join("Cargo.toml").exists() {
77            commands.push("cargo build".to_string());
78            commands.push("cargo test".to_string());
79        }
80
81        // Node.js project
82        if root.join("package.json").exists() {
83            // Check for common scripts
84            if let Ok(content) = std::fs::read_to_string(root.join("package.json")) {
85                if content.contains("\"build\"") {
86                    commands.push("npm run build".to_string());
87                }
88                if content.contains("\"test\"") {
89                    commands.push("npm test".to_string());
90                }
91                if content.contains("\"lint\"") {
92                    commands.push("npm run lint".to_string());
93                }
94                if content.contains("\"typecheck\"") {
95                    commands.push("npm run typecheck".to_string());
96                }
97            }
98        }
99
100        // Python project
101        if root.join("pyproject.toml").exists() || root.join("setup.py").exists() {
102            if root.join("pytest.ini").exists() || root.join("pyproject.toml").exists() {
103                commands.push("pytest".to_string());
104            }
105        }
106
107        // Go project
108        if root.join("go.mod").exists() {
109            commands.push("go build ./...".to_string());
110            commands.push("go test ./...".to_string());
111        }
112
113        Self {
114            commands,
115            stop_on_failure: true,
116            timeout_secs: 300,
117        }
118    }
119}
120
121/// Result of running validation
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ValidationResult {
124    /// Whether all checks passed
125    pub all_passed: bool,
126    /// List of failures (command names that failed)
127    pub failures: Vec<String>,
128    /// Detailed results per command
129    pub results: Vec<CommandResult>,
130}
131
132/// Result of a single command
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct CommandResult {
135    /// Command that was run
136    pub command: String,
137    /// Whether it passed
138    pub passed: bool,
139    /// Exit code
140    pub exit_code: Option<i32>,
141    /// Stdout (truncated)
142    pub stdout: String,
143    /// Stderr (truncated)
144    pub stderr: String,
145    /// Duration in seconds
146    pub duration_secs: f64,
147}
148
149/// Run backpressure validation
150pub fn run_validation(working_dir: &Path, config: &BackpressureConfig) -> Result<ValidationResult> {
151    let mut results = Vec::new();
152    let mut failures = Vec::new();
153    let mut all_passed = true;
154
155    for cmd_str in &config.commands {
156        println!("      Running: {}", cmd_str);
157
158        let start = std::time::Instant::now();
159        let result = run_command(working_dir, cmd_str, config.timeout_secs);
160        let duration = start.elapsed().as_secs_f64();
161
162        match result {
163            Ok((exit_code, stdout, stderr)) => {
164                let passed = exit_code == 0;
165                if !passed {
166                    all_passed = false;
167                    failures.push(cmd_str.clone());
168                }
169
170                results.push(CommandResult {
171                    command: cmd_str.clone(),
172                    passed,
173                    exit_code: Some(exit_code),
174                    stdout: truncate_output(&stdout, 1000),
175                    stderr: truncate_output(&stderr, 1000),
176                    duration_secs: duration,
177                });
178
179                if !passed && config.stop_on_failure {
180                    break;
181                }
182            }
183            Err(e) => {
184                all_passed = false;
185                failures.push(format!("{} (error: {})", cmd_str, e));
186
187                results.push(CommandResult {
188                    command: cmd_str.clone(),
189                    passed: false,
190                    exit_code: None,
191                    stdout: String::new(),
192                    stderr: e.to_string(),
193                    duration_secs: duration,
194                });
195
196                if config.stop_on_failure {
197                    break;
198                }
199            }
200        }
201    }
202
203    Ok(ValidationResult {
204        all_passed,
205        failures,
206        results,
207    })
208}
209
210/// Run a single command
211fn run_command(
212    working_dir: &Path,
213    cmd_str: &str,
214    _timeout_secs: u64,
215) -> Result<(i32, String, String)> {
216    // Parse command (simple split on spaces, handles basic cases)
217    let parts: Vec<&str> = cmd_str.split_whitespace().collect();
218    if parts.is_empty() {
219        anyhow::bail!("Empty command");
220    }
221
222    let output = Command::new(parts[0])
223        .args(&parts[1..])
224        .current_dir(working_dir)
225        .output()?;
226
227    let exit_code = output.status.code().unwrap_or(-1);
228    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
229    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
230
231    Ok((exit_code, stdout, stderr))
232}
233
234/// Truncate output to max length
235fn truncate_output(output: &str, max_len: usize) -> String {
236    if output.len() <= max_len {
237        output.to_string()
238    } else {
239        format!("{}...[truncated]", &output[..max_len])
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use tempfile::TempDir;
247
248    #[test]
249    fn test_auto_detect_rust() {
250        let tmp = TempDir::new().unwrap();
251        std::fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
252
253        let config = BackpressureConfig::auto_detect(tmp.path());
254        assert!(config.commands.contains(&"cargo build".to_string()));
255        assert!(config.commands.contains(&"cargo test".to_string()));
256    }
257
258    #[test]
259    fn test_auto_detect_empty() {
260        let tmp = TempDir::new().unwrap();
261        let config = BackpressureConfig::auto_detect(tmp.path());
262        assert!(config.commands.is_empty());
263    }
264
265    #[test]
266    fn test_truncate_output() {
267        assert_eq!(truncate_output("short", 100), "short");
268
269        let long = "a".repeat(200);
270        let truncated = truncate_output(&long, 50);
271        assert!(truncated.contains("truncated"));
272        assert!(truncated.len() < 200);
273    }
274}