scud/commands/swarm/
backpressure.rs1use anyhow::Result;
17use serde::{Deserialize, Serialize};
18use std::path::{Path, PathBuf};
19use std::process::Command;
20
21#[derive(Debug, Clone, Serialize, Deserialize, Default)]
23pub struct BackpressureConfig {
24 pub commands: Vec<String>,
26 #[serde(default = "default_stop_on_failure")]
28 pub stop_on_failure: bool,
29 #[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 }
41
42impl BackpressureConfig {
43 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 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 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 Ok(Self::auto_detect(&root))
69 }
70
71 fn auto_detect(root: &Path) -> Self {
73 let mut commands = Vec::new();
74
75 if root.join("Cargo.toml").exists() {
77 commands.push("cargo build".to_string());
78 commands.push("cargo test".to_string());
79 }
80
81 if root.join("package.json").exists() {
83 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ValidationResult {
124 pub all_passed: bool,
126 pub failures: Vec<String>,
128 pub results: Vec<CommandResult>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct CommandResult {
135 pub command: String,
137 pub passed: bool,
139 pub exit_code: Option<i32>,
141 pub stdout: String,
143 pub stderr: String,
145 pub duration_secs: f64,
147}
148
149pub 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
210fn run_command(
212 working_dir: &Path,
213 cmd_str: &str,
214 _timeout_secs: u64,
215) -> Result<(i32, String, String)> {
216 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
234fn 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}