1use std::collections::BTreeMap;
16use std::sync::{Mutex, OnceLock};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Outcome {
21 Accepted,
23 Skipped,
26 Gated,
29 Rejected(RejectReason),
31 EmptyDigest,
34 Superseded,
37 CliFailed,
39 Timeout,
41 NotRun,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum RejectReason {
49 Empty,
51 Other,
54 Format,
56}
57
58impl Outcome {
59 pub fn label(&self) -> &'static str {
61 match self {
62 Outcome::Accepted => "accepted",
63 Outcome::Skipped => "skipped",
64 Outcome::Gated => "gated",
65 Outcome::Rejected(RejectReason::Format) => "rejected:format",
66 Outcome::Rejected(RejectReason::Empty) => "rejected:empty",
67 Outcome::Rejected(RejectReason::Other) => "rejected:other",
68 Outcome::EmptyDigest => "empty_digest",
69 Outcome::Superseded => "superseded",
70 Outcome::CliFailed => "cli_failed",
71 Outcome::Timeout => "timeout",
72 Outcome::NotRun => "not_run",
73 }
74 }
75}
76
77pub fn classify_error(error: &str, cancelled: bool) -> Outcome {
80 if cancelled {
81 return Outcome::Superseded;
82 }
83 let lower = error.to_ascii_lowercase();
84 if lower.contains("timeout") || lower.contains("timed out") {
85 Outcome::Timeout
86 } else {
87 Outcome::CliFailed
88 }
89}
90
91type Counts = BTreeMap<&'static str, BTreeMap<&'static str, u64>>;
92
93fn counts() -> &'static Mutex<Counts> {
94 static COUNTS: OnceLock<Mutex<Counts>> = OnceLock::new();
95 COUNTS.get_or_init(|| Mutex::new(BTreeMap::new()))
96}
97
98const LOGGED_TEXT_MAX_CHARS: usize = 120;
100
101fn truncated(text: &str) -> String {
102 let mut out: String = text.chars().take(LOGGED_TEXT_MAX_CHARS).collect();
103 if text.chars().count() > LOGGED_TEXT_MAX_CHARS {
104 out.push('…');
105 }
106 out.replace('\n', " ⏎ ")
107}
108
109#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
111pub struct Timing {
112 pub queued_ms: u64,
114 pub run_ms: u64,
116}
117
118pub fn record(purpose: &'static str, entity: &str, outcome: Outcome, text: Option<&str>) {
121 record_timed(purpose, entity, outcome, text, None);
122}
123
124pub fn record_timed(purpose: &'static str, entity: &str, outcome: Outcome, text: Option<&str>, timing: Option<Timing>) {
130 if let Ok(mut map) = counts().lock() {
131 *map.entry(purpose)
132 .or_default()
133 .entry(outcome.label())
134 .or_default() += 1;
135 }
136 let text = match outcome {
137 Outcome::Rejected(_) | Outcome::Accepted => text.map(truncated),
138 _ => None,
139 };
140 let queued_ms = timing.map(|t| t.queued_ms);
141 let run_ms = timing.map(|t| t.run_ms);
142 tracing::info!(
143 purpose,
144 entity,
145 outcome = outcome.label(),
146 text = text.as_deref(),
147 queued_ms,
148 run_ms,
149 "ambient outcome"
150 );
151}
152
153pub fn snapshot() -> BTreeMap<String, BTreeMap<String, u64>> {
155 let Ok(map) = counts().lock() else {
156 return BTreeMap::new();
157 };
158 map.iter()
159 .map(|(purpose, by)| {
160 (
161 purpose.to_string(),
162 by.iter()
163 .map(|(label, n)| (label.to_string(), *n))
164 .collect(),
165 )
166 })
167 .collect()
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn a_failure_is_superseded_timed_out_or_a_cli_failure() {
176 assert_eq!(classify_error("cancelled", true), Outcome::Superseded);
177 assert_eq!(
178 classify_error("haiku call timed out after 15s", false),
179 Outcome::Timeout
180 );
181 assert_eq!(
182 classify_error("spawn failed: not found", false),
183 Outcome::CliFailed
184 );
185 }
186
187 #[test]
188 fn outcomes_are_counted_per_purpose() {
189 let p = "test_purpose_outcome_counts";
191 record(p, "b1", Outcome::Accepted, None);
192 record(p, "b1", Outcome::Accepted, None);
193 record(p, "b2", Outcome::Rejected(RejectReason::Format), Some("(none yet)"));
194 record(p, "b3", Outcome::Skipped, Some("SKIP"));
195 let snap = snapshot();
196 let by = &snap[p];
197 assert_eq!(by["accepted"], 2);
198 assert_eq!(by["rejected:format"], 1);
199 assert_eq!(by["skipped"], 1);
200 }
201
202 #[test]
203 fn logged_text_is_bounded_and_on_one_line() {
204 let long = "x".repeat(500);
205 assert_eq!(truncated(&long).chars().count(), LOGGED_TEXT_MAX_CHARS + 1);
206 assert!(!truncated("a\nb").contains('\n'));
207 }
208}