1use std::collections::BTreeMap;
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum FailureKind {
16 InvalidInput,
18 NotFound,
20 Permission,
22 PolicyBlocked,
24 Timeout,
26 Conflict,
28 TooLarge,
30 Unavailable,
32 Other,
34}
35
36pub const FAILURE_KINDS: [FailureKind; 9] = [
37 FailureKind::InvalidInput,
38 FailureKind::NotFound,
39 FailureKind::Permission,
40 FailureKind::PolicyBlocked,
41 FailureKind::Timeout,
42 FailureKind::Conflict,
43 FailureKind::TooLarge,
44 FailureKind::Unavailable,
45 FailureKind::Other,
46];
47
48impl FailureKind {
49 pub fn index(self) -> usize {
50 FAILURE_KINDS
51 .iter()
52 .position(|kind| *kind == self)
53 .unwrap_or(FAILURE_KINDS.len() - 1)
54 }
55
56 pub fn as_str(self) -> &'static str {
57 match self {
58 Self::InvalidInput => "invalid_input",
59 Self::NotFound => "not_found",
60 Self::Permission => "permission",
61 Self::PolicyBlocked => "policy_blocked",
62 Self::Timeout => "timeout",
63 Self::Conflict => "conflict",
64 Self::TooLarge => "too_large",
65 Self::Unavailable => "unavailable",
66 Self::Other => "other",
67 }
68 }
69}
70
71pub const RULES: &[(FailureKind, &[&str])] = &[
72 (
73 FailureKind::PolicyBlocked,
74 &[
75 "[policy blocked]",
76 "policy blocked",
77 "blocked by policy",
78 "denied by policy",
79 "allowlist",
80 "not permitted by",
81 "gateway denied",
82 "egress denied",
83 ],
84 ),
85 (
86 FailureKind::Timeout,
87 &["timed out", "timeout", "deadline exceeded"],
88 ),
89 (
90 FailureKind::Conflict,
91 &[
92 "old_string",
93 "not unique",
94 "no unique",
95 "conflict",
96 "mismatch",
97 "stale",
98 "changed since",
99 "hash does not match",
100 "anchor",
101 ],
102 ),
103 (
104 FailureKind::Permission,
105 &[
106 "permission denied",
107 "access denied",
108 "outside the project",
109 "outside project",
110 "outside of",
111 "path jail",
112 "escapes project root",
113 "read-only",
114 "readonly",
115 "operation not permitted",
116 ],
117 ),
118 (
119 FailureKind::NotFound,
120 &[
121 "not found",
122 "no such file",
123 "does not exist",
124 "doesn't exist",
125 "unknown tool",
126 "no matches",
127 "no such",
128 ],
129 ),
130 (
131 FailureKind::TooLarge,
132 &[
133 "too large",
134 "too long",
135 "exceeds",
136 "limit reached",
137 "over budget",
138 ],
139 ),
140 (
141 FailureKind::Unavailable,
142 &[
143 "unavailable",
144 "not running",
145 "connection refused",
146 "not installed",
147 "not configured",
148 "is disabled",
149 "not enabled",
150 "failed to connect",
151 "network",
152 ],
153 ),
154 (
155 FailureKind::InvalidInput,
156 &[
157 "invalid",
158 "missing required",
159 "missing field",
160 "required parameter",
161 "unknown field",
162 "unknown parameter",
163 "unknown action",
164 "expected",
165 "must be",
166 "cannot parse",
167 "failed to parse",
168 ],
169 ),
170];
171
172pub fn classify(message: &str) -> FailureKind {
174 let lowered = message.to_ascii_lowercase();
175 RULES
176 .iter()
177 .find(|(_, needles)| needles.iter().any(|needle| contains_word(&lowered, needle)))
178 .map_or(FailureKind::Other, |(kind, _)| *kind)
179}
180
181fn contains_word(haystack: &str, needle: &str) -> bool {
184 haystack.match_indices(needle).any(|(at, _)| {
185 haystack[..at]
186 .chars()
187 .next_back()
188 .is_none_or(|c| !c.is_ascii_alphanumeric())
189 })
190}
191
192pub type KindCounts = [u64; FAILURE_KINDS.len()];
194
195#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct Failure {
199 pub kind: FailureKind,
200 pub template: Option<String>,
201}
202
203impl Failure {
204 pub fn from_message(message: &str) -> Self {
206 Self {
207 kind: classify(message),
208 template: super::failure_template::message_template(message),
209 }
210 }
211
212 pub fn of_kind(kind: FailureKind) -> Self {
213 Self {
214 kind,
215 template: None,
216 }
217 }
218}
219
220pub fn sub_kinds(observed: &KindCounts, baseline: &KindCounts) -> KindCounts {
221 std::array::from_fn(|index| observed[index].saturating_sub(baseline[index]))
222}
223
224pub fn add_kinds(total: &mut KindCounts, delta: &KindCounts) {
225 for (slot, added) in total.iter_mut().zip(delta) {
226 *slot = slot.saturating_add(*added);
227 }
228}
229
230pub fn wire_kinds(counts: &KindCounts, failures: u64) -> Option<BTreeMap<FailureKind, u64>> {
232 let mut remaining = failures;
233 let map: BTreeMap<_, _> = FAILURE_KINDS
234 .iter()
235 .zip(counts)
236 .filter_map(|(kind, count)| {
237 let count = (*count).min(remaining);
238 remaining -= count;
239 (count > 0).then_some((*kind, count))
240 })
241 .collect();
242 (!map.is_empty()).then_some(map)
243}
244
245pub const MAX_FAILURE_MESSAGES: usize = 5;
247
248pub fn wire_messages(
251 messages: &BTreeMap<String, u64>,
252 tool: &str,
253 failures: u64,
254) -> Option<Vec<crate::core::telemetry_v2::FailureMessage>> {
255 let prefix = format!("{tool}\t");
256 let mut found: Vec<(&str, u64)> = messages
257 .iter()
258 .filter_map(|(key, count)| Some((key.strip_prefix(&prefix)?, *count)))
259 .filter(|(template, count)| *count > 0 && !template.is_empty())
260 .collect();
261 found.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
262 let mut remaining = failures;
263 let list: Vec<_> = found
264 .into_iter()
265 .take(MAX_FAILURE_MESSAGES)
266 .filter_map(|(template, count)| {
267 let count = count.min(remaining);
268 remaining -= count;
269 (count > 0).then(|| crate::core::telemetry_v2::FailureMessage {
270 template: template.to_string(),
271 count,
272 })
273 })
274 .collect();
275 (!list.is_empty()).then_some(list)
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn messages_map_to_closed_classes_most_specific_first() {
284 for (message, kind) in [
285 (
286 "[POLICY BLOCKED] command not in allowlist",
287 FailureKind::PolicyBlocked,
288 ),
289 ("command timed out after 120s", FailureKind::Timeout),
290 ("old_string not found in file", FailureKind::Conflict),
291 (
292 "CONFLICT: line 12 hash mismatch, re-read",
293 FailureKind::Conflict,
294 ),
295 ("path escapes project root: /etc", FailureKind::Permission),
296 ("Permission denied (os error 13)", FailureKind::Permission),
297 (
298 "No such file or directory (os error 2)",
299 FailureKind::NotFound,
300 ),
301 ("file is too large to read in full", FailureKind::TooLarge),
302 ("daemon not running", FailureKind::Unavailable),
303 (
304 "missing required parameter 'path'",
305 FailureKind::InvalidInput,
306 ),
307 ("invalid mode: banana", FailureKind::InvalidInput),
308 ("something odd happened", FailureKind::Other),
309 ("", FailureKind::Other),
310 ] {
311 assert_eq!(classify(message), kind, "{message}");
312 }
313 }
314
315 #[test]
316 fn every_kind_has_a_stable_wire_name_and_index() {
317 for (index, kind) in FAILURE_KINDS.iter().enumerate() {
318 assert_eq!(kind.index(), index);
319 assert_eq!(
320 serde_json::to_string(kind).unwrap(),
321 format!("\"{}\"", kind.as_str())
322 );
323 }
324 }
325}