formal_ai/promotion/
gates.rs1use std::path::Path;
9use std::process::Command;
10
11use crate::engine::stable_id;
12
13use super::{PromotionProposal, PromotionRatchet};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct GateCommandOutput {
18 pub succeeded: bool,
20 pub stdout: String,
22 pub stderr: String,
24}
25
26impl GateCommandOutput {
27 #[must_use]
29 pub fn success(stdout: impl Into<String>) -> Self {
30 Self {
31 succeeded: true,
32 stdout: stdout.into(),
33 stderr: String::new(),
34 }
35 }
36
37 #[must_use]
39 pub fn failure(stdout: impl Into<String>, stderr: impl Into<String>) -> Self {
40 Self {
41 succeeded: false,
42 stdout: stdout.into(),
43 stderr: stderr.into(),
44 }
45 }
46}
47
48pub fn replay_promotion_gates_with<F>(
55 mut proposals: Vec<PromotionProposal>,
56 mut execute: F,
57) -> Result<Vec<PromotionProposal>, String>
58where
59 F: FnMut(&str) -> Result<GateCommandOutput, String>,
60{
61 let mut evidence = Vec::new();
62 for mut gate in required_gates() {
63 let output = execute(&gate.runner).map_err(|error| {
64 format!(
65 "could not execute canonical gate `{}`: {error}",
66 gate.suite_id
67 )
68 })?;
69 let joined = format!(
70 "suite={}\nrunner={}\nsucceeded={}\nstdout:\n{}\nstderr:\n{}",
71 gate.suite_id, gate.runner, output.succeeded, output.stdout, output.stderr
72 );
73 gate.evidence_digest = Some(stable_id("promotion_gate_output", &joined));
74 gate.command_succeeded = output.succeeded;
75 if output.succeeded {
76 let (passed, failed) =
77 parse_pass_fail(&output.stdout, &output.stderr).ok_or_else(|| {
78 format!(
79 "canonical gate `{}` succeeded without a parseable pass/fail report",
80 gate.suite_id
81 )
82 })?;
83 gate.passed = passed;
84 gate.failed = failed;
85 } else {
86 gate.passed = 0;
87 gate.failed = 1;
88 }
89 evidence.push(gate);
90 }
91 for proposal in &mut proposals {
92 proposal.gates.clone_from(&evidence);
93 }
94 Ok(proposals)
95}
96
97pub fn replay_promotion_gates(
103 proposals: Vec<PromotionProposal>,
104 workspace_root: &Path,
105) -> Result<Vec<PromotionProposal>, String> {
106 replay_promotion_gates_with(proposals, |runner| {
107 let output = Command::new("sh")
108 .arg("-c")
109 .arg(runner)
110 .current_dir(workspace_root)
111 .output()
112 .map_err(|error| format!("failed to start `{runner}`: {error}"))?;
113 Ok(GateCommandOutput {
114 succeeded: output.status.success(),
115 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
116 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
117 })
118 })
119}
120
121pub(super) fn required_gates() -> Vec<PromotionRatchet> {
122 let mut gates = vec![
123 PromotionRatchet::coding_modification(0, 1),
124 PromotionRatchet::industry(0, 1),
125 PromotionRatchet::unit_specs(0, 1),
126 ];
127 for gate in &mut gates {
128 gate.command_succeeded = false;
129 }
130 gates
131}
132
133fn parse_pass_fail(stdout: &str, stderr: &str) -> Option<(usize, usize)> {
134 let combined = format!("{stdout}\n{stderr}");
135 for line in combined.lines().rev() {
136 if let (Some(passed), Some(failed)) =
137 (count_after(line, "passed="), count_after(line, "failed="))
138 {
139 return Some((passed, failed));
140 }
141 }
142 for line in combined.lines().rev() {
145 if line.contains("test result:") {
146 let passed = count_before(line, " passed;");
147 let failed = count_before(line, " failed;");
148 if let (Some(passed), Some(failed)) = (passed, failed) {
149 return Some((passed, failed));
150 }
151 }
152 }
153 None
154}
155
156fn count_after(line: &str, marker: &str) -> Option<usize> {
157 let tail = line.split_once(marker)?.1;
158 let digits: String = tail.chars().take_while(char::is_ascii_digit).collect();
159 digits.parse().ok()
160}
161
162fn count_before(line: &str, marker: &str) -> Option<usize> {
163 let head = line.split_once(marker)?.0;
164 head.split_whitespace().next_back()?.parse().ok()
165}