1use std::collections::HashMap;
2use std::path::Path;
3use std::time::Duration;
4
5use serde::{Deserialize, Serialize};
6
7use crate::security::sanitize::has_control_bytes;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum GateStatus {
15 NotRun,
16 Running,
17 Passed,
18 Failed,
19 TimedOut,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum DagGateStatus {
31 Pending,
32 Running,
33 Passed,
34 Failed,
35 Skipped,
36 TimedOut,
37}
38
39#[derive(Debug, Clone)]
41pub struct GateDefinition {
42 pub name: String,
44 pub command: String,
46 pub args: Vec<String>,
48 pub dependencies: Vec<String>,
50 pub timeout: Duration,
52 pub description: String,
54}
55
56#[derive(Debug, Clone)]
58pub struct GateDAG {
59 pub gates: Vec<GateDefinition>,
61 pub adjacency: HashMap<String, Vec<String>>,
63 pub execution_order: Vec<Vec<String>>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct GateResult {
70 pub name: String,
72 pub status: DagGateStatus,
74 pub exit_code: Option<i32>,
76 #[serde(skip)]
78 pub duration: Duration,
79 pub timestamp: String,
81 pub output: String,
83 pub skip_reason: Option<String>,
85}
86
87#[derive(Debug, Clone, Deserialize)]
89pub struct GatesConfig {
90 pub gate: Vec<GateTomlEntry>,
92}
93
94#[derive(Debug, Clone, Deserialize)]
96pub struct GateTomlEntry {
97 pub name: String,
99 pub command: String,
101 pub args: Option<Vec<String>>,
103 pub depends_on: Option<Vec<String>>,
105 pub timeout_secs: Option<u64>,
107 pub description: Option<String>,
109}
110
111#[derive(Debug, Clone)]
113pub struct ValidationGate {
114 pub script_name: String,
115 pub description: String,
116 pub status: GateStatus,
117 pub last_exit_code: Option<i32>,
118 pub last_duration: Option<Duration>,
119}
120
121impl ValidationGate {
122 pub fn new(script_name: String, description: String) -> Self {
124 Self {
125 script_name,
126 description,
127 status: GateStatus::NotRun,
128 last_exit_code: None,
129 last_duration: None,
130 }
131 }
132}
133
134pub fn extract_validation_gates(workspace_root: &Path) -> Vec<ValidationGate> {
139 let package_json_path = workspace_root.join("package.json");
140
141 let content = match std::fs::read_to_string(&package_json_path) {
142 Ok(c) => c,
143 Err(_) => return Vec::new(),
144 };
145
146 let parsed: serde_json::Value = match serde_json::from_str(&content) {
147 Ok(v) => v,
148 Err(_) => return Vec::new(),
149 };
150
151 let scripts = match parsed.get("scripts").and_then(|s| s.as_object()) {
152 Some(s) => s,
153 None => return Vec::new(),
154 };
155
156 let mut gates: Vec<ValidationGate> = scripts
157 .iter()
158 .filter(|(name, _)| name.starts_with("validate:"))
159 .filter(|(name, value)| {
160 !has_control_bytes(name) && !value.as_str().is_some_and(has_control_bytes)
161 })
162 .map(|(name, value)| {
163 let description = value.as_str().unwrap_or("").to_string();
164 ValidationGate::new(name.clone(), description)
165 })
166 .collect();
167
168 gates.sort_by_key(|a| a.script_name.clone());
169 gates
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use tempfile::TempDir;
176
177 #[test]
178 fn extract_gates_from_valid_package_json() {
179 let tmp = TempDir::new().unwrap();
180 let content = r#"{
181 "name": "test",
182 "scripts": {
183 "build": "tsc",
184 "validate:lint": "eslint .",
185 "validate:types": "tsc --noEmit",
186 "test": "jest"
187 }
188 }"#;
189 std::fs::write(tmp.path().join("package.json"), content).unwrap();
190
191 let gates = extract_validation_gates(tmp.path());
192 assert_eq!(gates.len(), 2);
193 assert_eq!(gates[0].script_name, "validate:lint");
194 assert_eq!(gates[0].description, "eslint .");
195 assert_eq!(gates[0].status, GateStatus::NotRun);
196 assert_eq!(gates[1].script_name, "validate:types");
197 }
198
199 #[test]
200 fn extract_gates_missing_package_json() {
201 let tmp = TempDir::new().unwrap();
202 let gates = extract_validation_gates(tmp.path());
203 assert!(gates.is_empty());
204 }
205
206 #[test]
207 fn extract_gates_no_scripts() {
208 let tmp = TempDir::new().unwrap();
209 std::fs::write(tmp.path().join("package.json"), r#"{"name": "test"}"#).unwrap();
210 let gates = extract_validation_gates(tmp.path());
211 assert!(gates.is_empty());
212 }
213
214 #[test]
215 fn extract_gates_no_validate_prefix() {
216 let tmp = TempDir::new().unwrap();
217 let content = r#"{
218 "scripts": {
219 "build": "tsc",
220 "test": "jest"
221 }
222 }"#;
223 std::fs::write(tmp.path().join("package.json"), content).unwrap();
224 let gates = extract_validation_gates(tmp.path());
225 assert!(gates.is_empty());
226 }
227
228 #[test]
229 fn validation_gate_new_defaults() {
230 let gate = ValidationGate::new("validate:test".to_string(), "run tests".to_string());
231 assert_eq!(gate.status, GateStatus::NotRun);
232 assert_eq!(gate.last_exit_code, None);
233 assert_eq!(gate.last_duration, None);
234 }
235
236 #[test]
237 fn extract_gates_rejects_control_bytes() {
238 let tmp = TempDir::new().unwrap();
239 let content = "{\n \"scripts\": {\n \"validate:good\": \"ok\",\n \"validate:\\u001b[31mbad\": \"bad\"\n }\n}";
240 std::fs::write(tmp.path().join("package.json"), content).unwrap();
241 let gates = extract_validation_gates(tmp.path());
242 assert_eq!(gates.len(), 1);
243 assert_eq!(gates[0].script_name, "validate:good");
244 }
245
246 #[test]
249 fn dag_gate_status_round_trip() {
250 let statuses = [
251 DagGateStatus::Pending,
252 DagGateStatus::Running,
253 DagGateStatus::Passed,
254 DagGateStatus::Failed,
255 DagGateStatus::Skipped,
256 DagGateStatus::TimedOut,
257 ];
258 for s in &statuses {
259 let json = serde_json::to_string(s).unwrap();
260 let decoded: DagGateStatus = serde_json::from_str(&json).unwrap();
261 assert_eq!(&decoded, s);
262 }
263 }
264
265 #[test]
266 fn dag_gate_status_serialized_strings() {
267 assert_eq!(
268 serde_json::to_string(&DagGateStatus::Pending).unwrap(),
269 "\"pending\""
270 );
271 assert_eq!(
272 serde_json::to_string(&DagGateStatus::Running).unwrap(),
273 "\"running\""
274 );
275 assert_eq!(
276 serde_json::to_string(&DagGateStatus::Passed).unwrap(),
277 "\"passed\""
278 );
279 assert_eq!(
280 serde_json::to_string(&DagGateStatus::Failed).unwrap(),
281 "\"failed\""
282 );
283 assert_eq!(
284 serde_json::to_string(&DagGateStatus::Skipped).unwrap(),
285 "\"skipped\""
286 );
287 assert_eq!(
288 serde_json::to_string(&DagGateStatus::TimedOut).unwrap(),
289 "\"timed_out\""
290 );
291 }
292
293 #[test]
294 fn gate_result_round_trip() {
295 let result = GateResult {
296 name: "validate:lint".to_string(),
297 status: DagGateStatus::Passed,
298 exit_code: Some(0),
299 duration: Duration::from_millis(1500),
300 timestamp: "2025-01-01T00:00:00.000Z".to_string(),
301 output: "All checks passed.".to_string(),
302 skip_reason: None,
303 };
304 let json = serde_json::to_string(&result).unwrap();
305 let decoded: GateResult = serde_json::from_str(&json).unwrap();
306 assert_eq!(decoded.name, "validate:lint");
307 assert_eq!(decoded.status, DagGateStatus::Passed);
308 assert_eq!(decoded.exit_code, Some(0));
309 assert_eq!(decoded.timestamp, "2025-01-01T00:00:00.000Z");
310 assert_eq!(decoded.output, "All checks passed.");
311 assert!(decoded.skip_reason.is_none());
312 let _ = result.duration;
314 }
315
316 #[test]
317 fn gate_result_skipped_round_trip() {
318 let result = GateResult {
319 name: "validate:integration".to_string(),
320 status: DagGateStatus::Skipped,
321 exit_code: None,
322 duration: Duration::ZERO,
323 timestamp: "2025-01-01T00:01:00.000Z".to_string(),
324 output: String::new(),
325 skip_reason: Some("dependency failed: validate:lint".to_string()),
326 };
327 let json = serde_json::to_string(&result).unwrap();
328 let decoded: GateResult = serde_json::from_str(&json).unwrap();
329 assert_eq!(decoded.status, DagGateStatus::Skipped);
330 assert!(decoded.exit_code.is_none());
331 assert_eq!(
332 decoded.skip_reason.as_deref(),
333 Some("dependency failed: validate:lint")
334 );
335 }
336
337 #[test]
338 fn gates_config_deserializes_from_toml() {
339 let toml_input = r#"
340[[gate]]
341name = "lint"
342command = "npm"
343args = ["run", "validate:lint"]
344timeout_secs = 60
345description = "Run ESLint"
346
347[[gate]]
348name = "types"
349command = "npm"
350args = ["run", "validate:types"]
351depends_on = ["lint"]
352timeout_secs = 120
353"#;
354 let config: GatesConfig = toml::from_str(toml_input).expect("parse gates.toml");
355 assert_eq!(config.gate.len(), 2);
356 assert_eq!(config.gate[0].name, "lint");
357 assert_eq!(config.gate[0].command, "npm");
358 assert_eq!(
359 config.gate[0].args.as_deref(),
360 Some(&["run".to_string(), "validate:lint".to_string()][..])
361 );
362 assert_eq!(config.gate[0].timeout_secs, Some(60));
363 assert_eq!(config.gate[0].description.as_deref(), Some("Run ESLint"));
364 assert!(config.gate[0].depends_on.is_none());
365
366 assert_eq!(config.gate[1].name, "types");
367 assert_eq!(
368 config.gate[1].depends_on.as_deref(),
369 Some(&["lint".to_string()][..])
370 );
371 assert!(config.gate[1].description.is_none());
372 }
373
374 #[test]
375 fn gate_toml_entry_minimal_required_fields() {
376 let toml_input = r#"
377[[gate]]
378name = "check"
379command = "cargo"
380"#;
381 let config: GatesConfig = toml::from_str(toml_input).expect("parse");
382 assert_eq!(config.gate.len(), 1);
383 assert_eq!(config.gate[0].name, "check");
384 assert!(config.gate[0].args.is_none());
385 assert!(config.gate[0].depends_on.is_none());
386 assert!(config.gate[0].timeout_secs.is_none());
387 assert!(config.gate[0].description.is_none());
388 }
389}