1use anyhow::Result;
93use serde::{Deserialize, Serialize};
94use std::path::{Path, PathBuf};
95use std::process::Command;
96
97#[derive(Debug, Clone, Serialize, Deserialize, Default)]
99pub struct BackpressureConfig {
100 pub commands: Vec<String>,
102 #[serde(default = "default_stop_on_failure")]
104 pub stop_on_failure: bool,
105 #[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 }
117
118impl BackpressureConfig {
119 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 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 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 Ok(Self::auto_detect(&root))
145 }
146
147 fn auto_detect(root: &Path) -> Self {
149 let mut commands = Vec::new();
150
151 if root.join("Cargo.toml").exists() {
153 commands.push("cargo build".to_string());
154 commands.push("cargo test".to_string());
155 }
156
157 if root.join("package.json").exists() {
159 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct ValidationResult {
200 pub all_passed: bool,
202 pub failures: Vec<String>,
204 pub results: Vec<CommandResult>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct CommandResult {
211 pub command: String,
213 pub passed: bool,
215 pub exit_code: Option<i32>,
217 pub stdout: String,
219 pub stderr: String,
221 pub duration_secs: f64,
223}
224
225pub 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
286fn 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 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 loop {
315 match child.try_wait()? {
316 Some(status) => {
317 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 if start.elapsed() > timeout {
334 let _ = child.kill();
336 let _ = child.wait(); 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
349fn 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}