1use serde::Serialize;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ExitClass {
12 Success,
14 Failure(i32),
16 KilledBySignal,
18}
19
20pub fn classify_exit(exit_code: Option<i32>) -> ExitClass {
21 match exit_code {
22 Some(0) => ExitClass::Success,
23 Some(code) => ExitClass::Failure(code),
24 None => ExitClass::KilledBySignal,
25 }
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum StageOutcome {
31 Passed,
32 Failed,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum MergeOutcome {
38 Merged,
39 Diverged,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct RunEvidence {
48 pub cancelled: bool,
50 pub exit: ExitClass,
51 pub commit_count: u64,
53 pub tests: Option<StageOutcome>,
56 pub merge: Option<MergeOutcome>,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
63#[serde(rename_all = "snake_case")]
64pub enum Outcome {
65 Merged,
66 Failed,
67 NeedsReview,
68 Cancelled,
69 Orphaned,
72}
73
74impl Outcome {
75 pub fn as_str(self) -> &'static str {
76 match self {
77 Self::Merged => "merged",
78 Self::Failed => "failed",
79 Self::NeedsReview => "needs_review",
80 Self::Cancelled => "cancelled",
81 Self::Orphaned => "orphaned",
82 }
83 }
84}
85
86pub fn wants_tests(exit: ExitClass, commit_count: u64) -> bool {
89 exit == ExitClass::Success && commit_count > 0
90}
91
92pub fn wants_merge(exit: ExitClass, commit_count: u64, tests: Option<StageOutcome>) -> bool {
96 wants_tests(exit, commit_count) && tests != Some(StageOutcome::Failed)
97}
98
99pub fn derive_outcome(evidence: &RunEvidence) -> Outcome {
119 if evidence.cancelled {
120 return Outcome::Cancelled;
121 }
122 if evidence.merge == Some(MergeOutcome::Merged) {
123 return Outcome::Merged;
124 }
125 if evidence.commit_count == 0 {
126 return Outcome::Failed;
127 }
128 Outcome::NeedsReview
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 fn evidence() -> RunEvidence {
136 RunEvidence {
137 cancelled: false,
138 exit: ExitClass::Success,
139 commit_count: 0,
140 tests: None,
141 merge: None,
142 }
143 }
144
145 #[test]
146 fn exit_zero_without_commits_is_not_successful_work() {
147 let outcome = derive_outcome(&evidence());
148 assert_ne!(outcome, Outcome::Merged);
149 assert_ne!(outcome, Outcome::Cancelled);
150 }
151
152 #[test]
153 fn a_successful_merge_is_the_only_path_to_merged() {
154 let merged = derive_outcome(&RunEvidence {
155 commit_count: 2,
156 tests: Some(StageOutcome::Passed),
157 merge: Some(MergeOutcome::Merged),
158 ..evidence()
159 });
160 assert_eq!(merged, Outcome::Merged);
161
162 let diverged = derive_outcome(&RunEvidence {
163 commit_count: 2,
164 tests: Some(StageOutcome::Passed),
165 merge: Some(MergeOutcome::Diverged),
166 ..evidence()
167 });
168 assert_ne!(diverged, Outcome::Merged);
169 }
170
171 #[test]
172 fn failed_tests_never_reach_merged() {
173 let outcome = derive_outcome(&RunEvidence {
174 commit_count: 1,
175 tests: Some(StageOutcome::Failed),
176 ..evidence()
177 });
178 assert_ne!(outcome, Outcome::Merged);
179 assert_ne!(outcome, Outcome::Cancelled);
180 }
181
182 #[test]
183 fn a_crashed_agent_with_commits_preserves_the_work_for_a_human() {
184 let outcome = derive_outcome(&RunEvidence {
185 exit: ExitClass::Failure(1),
186 commit_count: 3,
187 ..evidence()
188 });
189 assert_eq!(outcome, Outcome::NeedsReview);
190 }
191
192 #[test]
193 fn cancellation_wins_over_every_other_reading() {
194 let outcome = derive_outcome(&RunEvidence {
195 cancelled: true,
196 exit: ExitClass::KilledBySignal,
197 commit_count: 5,
198 tests: Some(StageOutcome::Passed),
199 merge: Some(MergeOutcome::Merged),
200 });
201 assert_eq!(outcome, Outcome::Cancelled);
202 }
203
204 #[test]
205 fn exit_classification_reads_the_wait_status() {
206 assert_eq!(classify_exit(Some(0)), ExitClass::Success);
207 assert_eq!(classify_exit(Some(2)), ExitClass::Failure(2));
208 assert_eq!(classify_exit(None), ExitClass::KilledBySignal);
209 }
210
211 #[test]
212 fn test_and_merge_gates_follow_the_evidence() {
213 assert!(wants_tests(ExitClass::Success, 1));
214 assert!(!wants_tests(ExitClass::Success, 0));
215 assert!(!wants_tests(ExitClass::Failure(1), 4));
216 assert!(!wants_tests(ExitClass::KilledBySignal, 4));
217
218 assert!(wants_merge(
219 ExitClass::Success,
220 1,
221 Some(StageOutcome::Passed)
222 ));
223 assert!(wants_merge(ExitClass::Success, 1, None));
224 assert!(!wants_merge(
225 ExitClass::Success,
226 1,
227 Some(StageOutcome::Failed)
228 ));
229 assert!(!wants_merge(ExitClass::Failure(1), 1, None));
230 }
231}