use crate::core::types::environment::{PromotionConfig, PromotionGate};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct GateResult {
pub gate_type: String,
pub passed: bool,
pub message: String,
}
#[derive(Debug, Clone)]
pub struct PromotionResult {
pub from: String,
pub to: String,
pub gates: Vec<GateResult>,
pub all_passed: bool,
pub auto_approve: bool,
}
impl PromotionResult {
pub fn failed_count(&self) -> usize {
self.gates.iter().filter(|g| !g.passed).count()
}
pub fn passed_count(&self) -> usize {
self.gates.iter().filter(|g| g.passed).count()
}
}
pub fn evaluate_gates(
config_file: &Path,
target_env: &str,
promotion: &PromotionConfig,
) -> PromotionResult {
let mut gate_results = Vec::new();
for gate in &promotion.gates {
let result = evaluate_single_gate(config_file, gate);
gate_results.push(result);
}
let all_passed = gate_results.iter().all(|g| g.passed);
PromotionResult {
from: promotion.from.clone(),
to: target_env.to_string(),
gates: gate_results,
all_passed,
auto_approve: promotion.auto_approve,
}
}
fn evaluate_single_gate(config_file: &Path, gate: &PromotionGate) -> GateResult {
if let Some(ref opts) = gate.validate {
evaluate_validate_gate(config_file, opts.deep)
} else if gate.policy.is_some() {
evaluate_policy_gate(config_file)
} else if let Some(ref opts) = gate.coverage {
evaluate_coverage_gate(opts.min)
} else if let Some(ref script) = gate.script {
evaluate_script_gate(script)
} else {
GateResult {
gate_type: "unknown".into(),
passed: false,
message: "no gate type configured".into(),
}
}
}
fn evaluate_validate_gate(config_file: &Path, deep: bool) -> GateResult {
let config = match crate::core::parser::parse_and_validate(config_file) {
Ok(c) => c,
Err(e) => {
return GateResult {
gate_type: "validate".into(),
passed: false,
message: format!("validation failed: {e}"),
};
}
};
let errors = crate::core::parser::validate_config(&config);
let mode = if deep { "deep" } else { "standard" };
if errors.is_empty() {
GateResult {
gate_type: "validate".into(),
passed: true,
message: format!("{mode} validation passed"),
}
} else {
GateResult {
gate_type: "validate".into(),
passed: false,
message: format!("{mode} validation: {} error(s)", errors.len()),
}
}
}
fn evaluate_policy_gate(config_file: &Path) -> GateResult {
let config = match crate::core::parser::parse_and_validate(config_file) {
Ok(c) => c,
Err(e) => {
return GateResult {
gate_type: "policy".into(),
passed: false,
message: format!("parse error: {e}"),
};
}
};
let result = crate::core::parser::evaluate_policies_full(&config);
if result.has_blocking_violations() {
GateResult {
gate_type: "policy".into(),
passed: false,
message: format!(
"{} error(s), {} warning(s)",
result.error_count(),
result.warning_count()
),
}
} else {
GateResult {
gate_type: "policy".into(),
passed: true,
message: format!(
"policy check passed ({} warning(s))",
result.warning_count()
),
}
}
}
fn evaluate_coverage_gate(min_coverage: u32) -> GateResult {
evaluate_coverage_gate_inner(min_coverage, run_llvm_cov)
}
fn evaluate_coverage_gate_inner(min_coverage: u32, runner: fn() -> Option<String>) -> GateResult {
let output = runner();
match output.and_then(|s| parse_coverage_from_output(&s)) {
Some(actual) => {
if actual >= min_coverage as f64 {
GateResult {
gate_type: "coverage".into(),
passed: true,
message: format!("coverage {actual:.1}% >= {min_coverage}%"),
}
} else {
GateResult {
gate_type: "coverage".into(),
passed: false,
message: format!("coverage {actual:.1}% < {min_coverage}% required"),
}
}
}
None => GateResult {
gate_type: "coverage".into(),
passed: true,
message: format!(
"coverage gate: minimum {min_coverage}% (advisory — llvm-cov not available)"
),
},
}
}
fn run_llvm_cov() -> Option<String> {
let output = std::process::Command::new("cargo")
.args(["llvm-cov", "--summary-only"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).to_string())
}
fn parse_coverage_from_output(stdout: &str) -> Option<f64> {
for line in stdout.lines() {
let trimmed = line.trim();
if trimmed.starts_with("TOTAL") || trimmed.contains("TOTAL") {
for word in trimmed.split_whitespace().rev() {
if let Some(pct_str) = word.strip_suffix('%') {
if let Ok(pct) = pct_str.parse::<f64>() {
return Some(pct);
}
}
}
}
}
None
}
fn evaluate_script_gate(script: &str) -> GateResult {
match std::process::Command::new("sh")
.args(["-c", script])
.output()
{
Ok(output) => {
if output.status.success() {
GateResult {
gate_type: "script".into(),
passed: true,
message: format!("script passed: {script}"),
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
GateResult {
gate_type: "script".into(),
passed: false,
message: format!("script failed (exit {}): {}", output.status, stderr.trim()),
}
}
}
Err(e) => GateResult {
gate_type: "script".into(),
passed: false,
message: format!("script error: {e}"),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::types::environment::*;
use tempfile::TempDir;
fn write_valid_config(dir: &Path) -> std::path::PathBuf {
let path = dir.join("forjar.yaml");
std::fs::write(
&path,
r#"
version: "1.0"
name: test
machines:
m1:
hostname: m1
addr: 127.0.0.1
resources:
pkg:
type: package
machine: m1
provider: apt
packages: [curl]
"#,
)
.unwrap();
path
}
#[test]
fn validate_gate_passes() {
let dir = TempDir::new().unwrap();
let cfg = write_valid_config(dir.path());
let result = evaluate_validate_gate(&cfg, false);
assert!(result.passed, "gate failed: {}", result.message);
assert_eq!(result.gate_type, "validate");
}
#[test]
fn validate_gate_fails() {
let dir = TempDir::new().unwrap();
let cfg = dir.path().join("forjar.yaml");
std::fs::write(&cfg, "invalid: yaml: [").unwrap();
let result = evaluate_validate_gate(&cfg, false);
assert!(!result.passed);
}
#[test]
fn policy_gate_no_policies() {
let dir = TempDir::new().unwrap();
let cfg = write_valid_config(dir.path());
let result = evaluate_policy_gate(&cfg);
assert!(result.passed);
}
#[test]
fn script_gate_passes() {
let result = evaluate_script_gate("true");
assert!(result.passed);
assert_eq!(result.gate_type, "script");
}
#[test]
fn script_gate_fails() {
let result = evaluate_script_gate("false");
assert!(!result.passed);
}
#[test]
fn coverage_gate_advisory_no_tool() {
fn no_tool() -> Option<String> {
None
}
let result = evaluate_coverage_gate_inner(95, no_tool);
assert!(result.passed);
assert!(result.message.contains("95%"));
assert!(result.message.contains("advisory"));
}
#[test]
fn coverage_gate_passes_threshold() {
fn mock_output() -> Option<String> {
Some(" TOTAL 1234 1111 96.5%\n".into())
}
let result = evaluate_coverage_gate_inner(95, mock_output);
assert!(result.passed);
assert!(result.message.contains("96.5%"));
}
#[test]
fn coverage_gate_fails_threshold() {
fn mock_output() -> Option<String> {
Some(" TOTAL 1234 900 72.9%\n".into())
}
let result = evaluate_coverage_gate_inner(95, mock_output);
assert!(!result.passed);
assert!(result.message.contains("72.9%"));
assert!(result.message.contains("95%"));
}
#[test]
fn parse_coverage_from_llvm_output() {
let output = r#"
Filename Regions Missed Regions Cover Functions Missed Functions Executed Lines Missed Lines Cover Branches Missed Branches Cover
---
TOTAL 5432 432 92.0% 1234 56 95.5% 18234 912 95.0% 0 0 -
"#;
let pct = parse_coverage_from_output(output);
assert!(pct.is_some());
}
#[test]
fn parse_coverage_no_total_line() {
let pct = parse_coverage_from_output("no total here\n");
assert!(pct.is_none());
}
#[test]
fn evaluate_all_gates() {
let dir = TempDir::new().unwrap();
let cfg = write_valid_config(dir.path());
let promotion = PromotionConfig {
from: "dev".into(),
gates: vec![
PromotionGate {
validate: Some(ValidateGateOptions {
deep: false,
exhaustive: false,
}),
..Default::default()
},
PromotionGate {
script: Some("true".into()),
..Default::default()
},
],
auto_approve: false,
rollout: None,
};
let result = evaluate_gates(&cfg, "staging", &promotion);
assert_eq!(result.from, "dev");
assert_eq!(result.to, "staging");
assert_eq!(result.gates.len(), 2);
assert!(result.all_passed);
assert_eq!(result.passed_count(), 2);
assert_eq!(result.failed_count(), 0);
}
#[test]
fn evaluate_gates_with_failure() {
let dir = TempDir::new().unwrap();
let cfg = write_valid_config(dir.path());
let promotion = PromotionConfig {
from: "dev".into(),
gates: vec![
PromotionGate {
validate: Some(ValidateGateOptions {
deep: false,
exhaustive: false,
}),
..Default::default()
},
PromotionGate {
script: Some("false".into()),
..Default::default()
},
],
auto_approve: true,
rollout: None,
};
let result = evaluate_gates(&cfg, "prod", &promotion);
assert!(!result.all_passed);
assert_eq!(result.failed_count(), 1);
assert!(result.auto_approve);
}
#[test]
fn unknown_gate_type() {
let dir = TempDir::new().unwrap();
let cfg = write_valid_config(dir.path());
let result = evaluate_single_gate(&cfg, &PromotionGate::default());
assert!(!result.passed);
assert_eq!(result.gate_type, "unknown");
}
}