use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailureKind {
InvalidInput,
NotFound,
Permission,
PolicyBlocked,
Timeout,
Conflict,
TooLarge,
Unavailable,
Other,
}
pub const FAILURE_KINDS: [FailureKind; 9] = [
FailureKind::InvalidInput,
FailureKind::NotFound,
FailureKind::Permission,
FailureKind::PolicyBlocked,
FailureKind::Timeout,
FailureKind::Conflict,
FailureKind::TooLarge,
FailureKind::Unavailable,
FailureKind::Other,
];
impl FailureKind {
pub fn index(self) -> usize {
FAILURE_KINDS
.iter()
.position(|kind| *kind == self)
.unwrap_or(FAILURE_KINDS.len() - 1)
}
pub fn as_str(self) -> &'static str {
match self {
Self::InvalidInput => "invalid_input",
Self::NotFound => "not_found",
Self::Permission => "permission",
Self::PolicyBlocked => "policy_blocked",
Self::Timeout => "timeout",
Self::Conflict => "conflict",
Self::TooLarge => "too_large",
Self::Unavailable => "unavailable",
Self::Other => "other",
}
}
}
pub const RULES: &[(FailureKind, &[&str])] = &[
(
FailureKind::PolicyBlocked,
&[
"[policy blocked]",
"policy blocked",
"blocked by policy",
"denied by policy",
"allowlist",
"not permitted by",
"gateway denied",
"egress denied",
],
),
(
FailureKind::Timeout,
&["timed out", "timeout", "deadline exceeded"],
),
(
FailureKind::Conflict,
&[
"old_string",
"not unique",
"no unique",
"conflict",
"mismatch",
"stale",
"changed since",
"hash does not match",
"anchor",
],
),
(
FailureKind::Permission,
&[
"permission denied",
"access denied",
"outside the project",
"outside project",
"outside of",
"path jail",
"escapes project root",
"read-only",
"readonly",
"operation not permitted",
],
),
(
FailureKind::NotFound,
&[
"not found",
"no such file",
"does not exist",
"doesn't exist",
"unknown tool",
"no matches",
"no such",
],
),
(
FailureKind::TooLarge,
&[
"too large",
"too long",
"exceeds",
"limit reached",
"over budget",
],
),
(
FailureKind::Unavailable,
&[
"unavailable",
"not running",
"connection refused",
"not installed",
"not configured",
"is disabled",
"not enabled",
"failed to connect",
"network",
],
),
(
FailureKind::InvalidInput,
&[
"invalid",
"missing required",
"missing field",
"required parameter",
"unknown field",
"unknown parameter",
"unknown action",
"expected",
"must be",
"cannot parse",
"failed to parse",
],
),
];
pub fn classify(message: &str) -> FailureKind {
let lowered = message.to_ascii_lowercase();
RULES
.iter()
.find(|(_, needles)| needles.iter().any(|needle| contains_word(&lowered, needle)))
.map_or(FailureKind::Other, |(kind, _)| *kind)
}
fn contains_word(haystack: &str, needle: &str) -> bool {
haystack.match_indices(needle).any(|(at, _)| {
haystack[..at]
.chars()
.next_back()
.is_none_or(|c| !c.is_ascii_alphanumeric())
})
}
pub type KindCounts = [u64; FAILURE_KINDS.len()];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Failure {
pub kind: FailureKind,
pub template: Option<String>,
}
impl Failure {
pub fn from_message(message: &str) -> Self {
Self {
kind: classify(message),
template: super::failure_template::message_template(message),
}
}
pub fn of_kind(kind: FailureKind) -> Self {
Self {
kind,
template: None,
}
}
}
pub fn sub_kinds(observed: &KindCounts, baseline: &KindCounts) -> KindCounts {
std::array::from_fn(|index| observed[index].saturating_sub(baseline[index]))
}
pub fn add_kinds(total: &mut KindCounts, delta: &KindCounts) {
for (slot, added) in total.iter_mut().zip(delta) {
*slot = slot.saturating_add(*added);
}
}
pub fn wire_kinds(counts: &KindCounts, failures: u64) -> Option<BTreeMap<FailureKind, u64>> {
let mut remaining = failures;
let map: BTreeMap<_, _> = FAILURE_KINDS
.iter()
.zip(counts)
.filter_map(|(kind, count)| {
let count = (*count).min(remaining);
remaining -= count;
(count > 0).then_some((*kind, count))
})
.collect();
(!map.is_empty()).then_some(map)
}
pub const MAX_FAILURE_MESSAGES: usize = 5;
pub fn wire_messages(
messages: &BTreeMap<String, u64>,
tool: &str,
failures: u64,
) -> Option<Vec<crate::core::telemetry_v2::FailureMessage>> {
let prefix = format!("{tool}\t");
let mut found: Vec<(&str, u64)> = messages
.iter()
.filter_map(|(key, count)| Some((key.strip_prefix(&prefix)?, *count)))
.filter(|(template, count)| *count > 0 && !template.is_empty())
.collect();
found.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
let mut remaining = failures;
let list: Vec<_> = found
.into_iter()
.take(MAX_FAILURE_MESSAGES)
.filter_map(|(template, count)| {
let count = count.min(remaining);
remaining -= count;
(count > 0).then(|| crate::core::telemetry_v2::FailureMessage {
template: template.to_string(),
count,
})
})
.collect();
(!list.is_empty()).then_some(list)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn messages_map_to_closed_classes_most_specific_first() {
for (message, kind) in [
(
"[POLICY BLOCKED] command not in allowlist",
FailureKind::PolicyBlocked,
),
("command timed out after 120s", FailureKind::Timeout),
("old_string not found in file", FailureKind::Conflict),
(
"CONFLICT: line 12 hash mismatch, re-read",
FailureKind::Conflict,
),
("path escapes project root: /etc", FailureKind::Permission),
("Permission denied (os error 13)", FailureKind::Permission),
(
"No such file or directory (os error 2)",
FailureKind::NotFound,
),
("file is too large to read in full", FailureKind::TooLarge),
("daemon not running", FailureKind::Unavailable),
(
"missing required parameter 'path'",
FailureKind::InvalidInput,
),
("invalid mode: banana", FailureKind::InvalidInput),
("something odd happened", FailureKind::Other),
("", FailureKind::Other),
] {
assert_eq!(classify(message), kind, "{message}");
}
}
#[test]
fn every_kind_has_a_stable_wire_name_and_index() {
for (index, kind) in FAILURE_KINDS.iter().enumerate() {
assert_eq!(kind.index(), index);
assert_eq!(
serde_json::to_string(kind).unwrap(),
format!("\"{}\"", kind.as_str())
);
}
}
}