Skip to main content

vfa_tui/models/
gate.rs

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/// Status of a validation gate (v1 model — used by `ValidationGate` and app.rs).
10///
11/// The DAG executor uses [`DagGateStatus`] which additionally has a `Pending`
12/// and `Skipped` variant to represent pre-execution and cascade-skip states.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum GateStatus {
15    NotRun,
16    Running,
17    Passed,
18    Failed,
19    TimedOut,
20}
21
22// ── DAG executor types ────────────────────────────────────────────────────────
23
24/// Runtime status for a gate inside the DAG executor.
25///
26/// Distinct from [`GateStatus`] to avoid breaking existing consumers while
27/// adding the `Pending` / `Skipped` states required by the DAG model.
28#[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/// A fully-resolved gate definition ready for DAG execution.
40#[derive(Debug, Clone)]
41pub struct GateDefinition {
42    /// Unique gate name within the DAG (matches `GateTomlEntry::name`).
43    pub name: String,
44    /// Executable or script path to run.
45    pub command: String,
46    /// Arguments to pass to the command.
47    pub args: Vec<String>,
48    /// Names of gates that must succeed before this gate can run.
49    pub dependencies: Vec<String>,
50    /// Maximum wall-clock time allowed for this gate.
51    pub timeout: Duration,
52    /// Human-readable description shown in the TUI.
53    pub description: String,
54}
55
56/// The in-memory gate dependency graph.
57#[derive(Debug, Clone)]
58pub struct GateDAG {
59    /// All gate definitions in this DAG.
60    pub gates: Vec<GateDefinition>,
61    /// Maps each gate name → list of gates that depend on it (i.e. downstream edges).
62    pub adjacency: HashMap<String, Vec<String>>,
63    /// Topologically sorted execution layers; gates within the same layer can run in parallel.
64    pub execution_order: Vec<Vec<String>>,
65}
66
67/// Result produced by executing a single gate.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct GateResult {
70    /// Name of the gate that was executed.
71    pub name: String,
72    /// Terminal status after execution.
73    pub status: DagGateStatus,
74    /// Process exit code; `None` if the gate was skipped or timed out before exit.
75    pub exit_code: Option<i32>,
76    /// Wall-clock duration of the execution.
77    #[serde(skip)]
78    pub duration: Duration,
79    /// ISO 8601 timestamp of when the gate started.
80    pub timestamp: String,
81    /// Combined stdout+stderr captured from the subprocess.
82    pub output: String,
83    /// Human-readable reason why the gate was skipped, if applicable.
84    pub skip_reason: Option<String>,
85}
86
87/// Top-level structure parsed from `gates.toml`.
88#[derive(Debug, Clone, Deserialize)]
89pub struct GatesConfig {
90    /// Array of gate entries (`[[gate]]` in TOML).
91    pub gate: Vec<GateTomlEntry>,
92}
93
94/// One `[[gate]]` entry as deserialized from `gates.toml`.
95#[derive(Debug, Clone, Deserialize)]
96pub struct GateTomlEntry {
97    /// Unique gate name within this config file.
98    pub name: String,
99    /// Executable or script path.
100    pub command: String,
101    /// Optional arguments list.
102    pub args: Option<Vec<String>>,
103    /// Names of gates that must complete first.
104    pub depends_on: Option<Vec<String>>,
105    /// Timeout in seconds; uses a default if absent.
106    pub timeout_secs: Option<u64>,
107    /// Optional human-readable description.
108    pub description: Option<String>,
109}
110
111/// A validation gate represents a script that must pass before export.
112#[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    /// Create a new validation gate with NotRun status.
123    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
134/// Extract validation gates from the package.json in the workspace root.
135///
136/// Looks for scripts matching the pattern `validate:*` and creates a
137/// ValidationGate for each with status NotRun.
138pub 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    // ── DAG model tests ───────────────────────────────────────────────────────
247
248    #[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        // duration is skipped in serde — just verify the field exists on the struct
313        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}