use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
use std::time::Duration;
use crate::error::TuiError;
use crate::models::gate::{extract_validation_gates, GateDAG, GateDefinition, GatesConfig};
const DEFAULT_TIMEOUT_SECS: u64 = 300;
pub struct GateDagExecutor {
pub concurrency_limit: usize,
}
impl GateDagExecutor {
pub fn new(concurrency_limit: usize) -> Self {
Self {
concurrency_limit: if concurrency_limit == 0 {
4
} else {
concurrency_limit
},
}
}
}
pub fn parse_gates(
gates_toml_path: Option<&Path>,
workspace_root: &Path,
) -> Result<Vec<GateDefinition>, TuiError> {
if let Some(toml_path) = gates_toml_path {
if toml_path.exists() {
return parse_gates_toml(toml_path);
}
}
let inferred = infer_gates_from_package_json(workspace_root);
Ok(inferred)
}
fn parse_gates_toml(path: &Path) -> Result<Vec<GateDefinition>, TuiError> {
let raw = std::fs::read_to_string(path).map_err(|e| TuiError::GateConfigParse {
path: path.display().to_string(),
detail: e.to_string(),
})?;
let config: GatesConfig = toml::from_str(&raw).map_err(|e| TuiError::GateConfigParse {
path: path.display().to_string(),
detail: e.to_string(),
})?;
let defs: Vec<GateDefinition> = config
.gate
.into_iter()
.map(|entry| GateDefinition {
name: entry.name,
command: entry.command,
args: entry.args.unwrap_or_default(),
dependencies: entry.depends_on.unwrap_or_default(),
timeout: Duration::from_secs(entry.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS)),
description: entry.description.unwrap_or_default(),
})
.collect();
Ok(defs)
}
fn infer_gates_from_package_json(workspace_root: &Path) -> Vec<GateDefinition> {
extract_validation_gates(workspace_root)
.into_iter()
.map(|vg| GateDefinition {
name: vg.script_name.clone(),
command: "npm".to_string(),
args: vec!["run".to_string(), vg.script_name],
dependencies: Vec::new(),
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
description: vg.description,
})
.collect()
}
pub fn build_dag(gates: Vec<GateDefinition>) -> Result<GateDAG, TuiError> {
let names: HashSet<String> = gates.iter().map(|g| g.name.clone()).collect();
let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
let mut in_degree: HashMap<String, usize> = HashMap::new();
for g in &gates {
adjacency.entry(g.name.clone()).or_default();
in_degree.entry(g.name.clone()).or_insert(0);
}
for g in &gates {
for dep in &g.dependencies {
if !names.contains(dep.as_str()) {
return Err(TuiError::GateConfigParse {
path: "gates".to_string(),
detail: format!("gate '{}' depends on unknown gate '{}'", g.name, dep),
});
}
adjacency
.entry(dep.clone())
.or_default()
.push(g.name.clone());
*in_degree.entry(g.name.clone()).or_insert(0) += 1;
}
}
let mut queue: VecDeque<String> = in_degree
.iter()
.filter(|(_, &d)| d == 0)
.map(|(name, _)| name.clone())
.collect();
let mut sorted_queue: Vec<String> = queue.drain(..).collect();
sorted_queue.sort();
queue.extend(sorted_queue);
let mut execution_order: Vec<Vec<String>> = Vec::new();
let mut processed = 0usize;
while !queue.is_empty() {
let layer_size = queue.len();
let mut layer: Vec<String> = queue.drain(..layer_size).collect();
layer.sort(); processed += layer.len();
let mut next: Vec<String> = Vec::new();
for gate_name in &layer {
if let Some(dependents) = adjacency.get(gate_name) {
for dep in dependents {
let deg = in_degree.get_mut(dep).expect("degree must exist");
*deg -= 1;
if *deg == 0 {
next.push(dep.clone());
}
}
}
}
execution_order.push(layer);
next.sort();
queue.extend(next);
}
if processed < gates.len() {
let cycle_gates: Vec<String> = in_degree
.iter()
.filter(|(_, &d)| d > 0)
.map(|(name, _)| name.clone())
.collect::<std::collections::BTreeSet<_>>() .into_iter()
.collect();
return Err(TuiError::GateCycle {
gates: cycle_gates.join(", "),
});
}
Ok(GateDAG {
gates,
adjacency,
execution_order,
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_gate(name: &str, deps: &[&str]) -> GateDefinition {
GateDefinition {
name: name.to_string(),
command: "echo".to_string(),
args: vec![name.to_string()],
dependencies: deps.iter().map(|s| s.to_string()).collect(),
timeout: Duration::from_secs(60),
description: String::new(),
}
}
#[test]
fn parse_gates_from_valid_toml() {
let tmp = TempDir::new().unwrap();
let toml = r#"
[[gate]]
name = "lint"
command = "npm"
args = ["run", "validate:lint"]
timeout_secs = 60
[[gate]]
name = "types"
command = "npm"
args = ["run", "validate:types"]
depends_on = ["lint"]
"#;
let path = tmp.path().join("gates.toml");
std::fs::write(&path, toml).unwrap();
let gates = parse_gates(Some(&path), tmp.path()).unwrap();
assert_eq!(gates.len(), 2);
assert_eq!(gates[0].name, "lint");
assert_eq!(gates[1].name, "types");
assert_eq!(gates[1].dependencies, vec!["lint"]);
}
#[test]
fn parse_gates_falls_back_to_package_json() {
let tmp = TempDir::new().unwrap();
let pkg = r#"{
"scripts": {
"validate:lint": "eslint .",
"validate:types": "tsc --noEmit"
}
}"#;
std::fs::write(tmp.path().join("package.json"), pkg).unwrap();
let gates = parse_gates(None, tmp.path()).unwrap();
assert_eq!(gates.len(), 2);
assert!(gates.iter().any(|g| g.name == "validate:lint"));
}
#[test]
fn parse_gates_toml_path_missing_falls_back_to_package_json() {
let tmp = TempDir::new().unwrap();
let non_existent = tmp.path().join("gates.toml"); let pkg = r#"{"scripts": {"validate:lint": "eslint ."}}"#;
std::fs::write(tmp.path().join("package.json"), pkg).unwrap();
let gates = parse_gates(Some(&non_existent), tmp.path()).unwrap();
assert_eq!(gates.len(), 1);
assert_eq!(gates[0].name, "validate:lint");
}
#[test]
fn parse_gates_invalid_toml_returns_error() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("gates.toml");
std::fs::write(&path, "[[gate]\nname = \"oops\"").unwrap();
let result = parse_gates(Some(&path), tmp.path());
assert!(matches!(result, Err(TuiError::GateConfigParse { .. })));
}
#[test]
fn inferred_gates_have_npm_run_command() {
let tmp = TempDir::new().unwrap();
let pkg = r#"{"scripts": {"validate:schema": "node check-schema.js"}}"#;
std::fs::write(tmp.path().join("package.json"), pkg).unwrap();
let gates = parse_gates(None, tmp.path()).unwrap();
assert_eq!(gates[0].command, "npm");
assert_eq!(gates[0].args, vec!["run", "validate:schema"]);
assert!(gates[0].dependencies.is_empty());
assert_eq!(gates[0].timeout, Duration::from_secs(DEFAULT_TIMEOUT_SECS));
}
#[test]
fn build_dag_empty_gates() {
let dag = build_dag(vec![]).unwrap();
assert!(dag.gates.is_empty());
assert!(dag.adjacency.is_empty());
assert!(dag.execution_order.is_empty());
}
#[test]
fn build_dag_single_gate() {
let dag = build_dag(vec![make_gate("lint", &[])]).unwrap();
assert_eq!(dag.execution_order, vec![vec!["lint".to_string()]]);
}
#[test]
fn build_dag_linear_chain() {
let gates = vec![
make_gate("a", &[]),
make_gate("b", &["a"]),
make_gate("c", &["b"]),
];
let dag = build_dag(gates).unwrap();
assert_eq!(dag.execution_order.len(), 3);
assert_eq!(dag.execution_order[0], vec!["a"]);
assert_eq!(dag.execution_order[1], vec!["b"]);
assert_eq!(dag.execution_order[2], vec!["c"]);
}
#[test]
fn build_dag_parallel_roots() {
let gates = vec![
make_gate("a", &[]),
make_gate("b", &[]),
make_gate("c", &["a", "b"]),
];
let dag = build_dag(gates).unwrap();
assert_eq!(dag.execution_order.len(), 2);
assert_eq!(dag.execution_order[0], vec!["a", "b"]); assert_eq!(dag.execution_order[1], vec!["c"]);
}
#[test]
fn build_dag_cycle_detected() {
let gates = vec![
make_gate("a", &["c"]),
make_gate("b", &["a"]),
make_gate("c", &["b"]),
];
let result = build_dag(gates);
assert!(
matches!(result, Err(TuiError::GateCycle { .. })),
"expected GateCycle, got: {:?}",
result
);
if let Err(TuiError::GateCycle { gates }) = result {
assert!(gates.contains('a') || gates.contains('b') || gates.contains('c'));
}
}
#[test]
fn build_dag_unknown_dependency_returns_config_error() {
let gates = vec![make_gate("lint", &["nonexistent"])];
let result = build_dag(gates);
assert!(matches!(result, Err(TuiError::GateConfigParse { .. })));
}
#[test]
fn build_dag_adjacency_maps_downstream() {
let gates = vec![
make_gate("lint", &[]),
make_gate("types", &["lint"]),
make_gate("integration", &["types"]),
];
let dag = build_dag(gates).unwrap();
assert_eq!(
dag.adjacency.get("lint").unwrap(),
&vec!["types".to_string()]
);
assert_eq!(
dag.adjacency.get("types").unwrap(),
&vec!["integration".to_string()]
);
assert!(dag.adjacency.get("integration").unwrap().is_empty());
}
#[test]
fn build_dag_every_gate_appears_exactly_once() {
let gates = vec![
make_gate("a", &[]),
make_gate("b", &["a"]),
make_gate("c", &["a"]),
make_gate("d", &["b", "c"]),
];
let dag = build_dag(gates).unwrap();
let all: Vec<&String> = dag.execution_order.iter().flatten().collect();
assert_eq!(all.len(), 4);
let unique: HashSet<&&String> = all.iter().collect();
assert_eq!(unique.len(), 4);
}
#[test]
fn build_dag_default_timeout_applied() {
let tmp = TempDir::new().unwrap();
let pkg = r#"{"scripts": {"validate:test": "jest"}}"#;
std::fs::write(tmp.path().join("package.json"), pkg).unwrap();
let gates = parse_gates(None, tmp.path()).unwrap();
assert_eq!(gates[0].timeout, Duration::from_secs(DEFAULT_TIMEOUT_SECS));
}
}