use std::sync::OnceLock;
use regex::Regex;
#[path = "failure_vocabulary.rs"]
mod vocabulary;
pub const MAX_TEMPLATE_CHARS: usize = 160;
const MAX_INPUT_CHARS: usize = 400;
const KNOWN_IDENTIFIERS: &[&str] = &[
"old_string",
"new_string",
"replace_all",
"file_path",
"start_line",
"end_line",
"max_results",
"dry_run",
"old_text",
"new_text",
"replace_unique",
"set_line",
"replace_lines",
"insert_after",
"replace_symbol",
"timeout_ms",
"run_in_background",
"job_id",
"doc_id",
"if_version",
];
const EDGE: &[char] = &[
'(', ')', '[', ']', '{', '}', ',', ';', ':', '.', '!', '?', '<', '>',
];
fn quoted() -> &'static Regex {
static QUOTED: OnceLock<Regex> = OnceLock::new();
QUOTED.get_or_init(|| {
Regex::new(r#""[^"\n]{0,300}"|`[^`\n]{0,300}`|'[^'\s][^'\n]{0,300}'|«[^»\n]{0,300}»"#)
.expect("static regex")
})
}
fn is_known_identifier(token: &str) -> bool {
token.starts_with("ctx_")
&& token[4..]
.bytes()
.all(|b| b.is_ascii_lowercase() || b == b'_')
|| KNOWN_IDENTIFIERS.contains(&token)
}
fn is_vocabulary_word(word: &str) -> bool {
let lower = word.to_ascii_lowercase();
vocabulary::VOCABULARY
.binary_search(&lower.as_str())
.is_ok()
}
fn is_own_word(word: &str) -> bool {
is_vocabulary_word(word)
|| word.strip_suffix("'s").is_some_and(is_vocabulary_word)
|| (word.contains('-')
&& word
.split('-')
.all(|part| !part.is_empty() && is_vocabulary_word(part)))
}
fn starts_path(core: &str) -> bool {
if core.contains("://") {
return false;
}
let bytes = core.as_bytes();
let drive = bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'\\' | b'/');
(core.len() > 1 && core.starts_with('/'))
|| core.starts_with('~')
|| ["./", "../", ".\\", "..\\", "\\\\"]
.iter()
.any(|prefix| core.starts_with(prefix))
|| drive
|| ((core.contains('/') || core.contains('\\')) && core.contains('.'))
}
fn is_file_name(core: &str) -> bool {
match core.rsplit_once('.') {
Some((stem, ext)) => {
!stem.is_empty()
&& (1..=8).contains(&ext.len())
&& ext.bytes().all(|b| b.is_ascii_alphanumeric())
&& ext.bytes().any(|b| b.is_ascii_alphabetic())
}
None => false,
}
}
fn is_identifier_like(token: &str) -> bool {
let has_inner_upper = token.chars().skip(1).any(|c| c.is_ascii_uppercase());
let has_lower = token.chars().any(|c| c.is_ascii_lowercase());
token.contains("::")
|| token.contains('_')
|| token.contains('$')
|| token.contains('#')
|| token.contains('=')
|| (has_inner_upper && has_lower)
|| token.len() > 24
}
fn closes_path(token: &str) -> bool {
token.ends_with([':', ',', ';', '.', ')', ']', '!', '?'])
}
fn opens_clause(token: &str) -> bool {
token.starts_with(['(', '[', '{', '<', '‹'])
|| matches!(token, "—" | "–" | "-" | "--" | "->" | "=>" | "|")
}
fn continues_path(core: &str) -> bool {
is_file_name(core)
|| ((core.contains('/') || core.contains('\\'))
&& !core
.split(['/', '\\'])
.all(|part| !part.is_empty() && is_own_word(part)))
}
fn path_span(tokens: &[&str], start: usize) -> Option<usize> {
let core = tokens[start].trim_matches(EDGE);
if core.starts_with('‹') || !starts_path(core) {
return None;
}
let mut end = start;
let mut probe = start;
while !closes_path(tokens[probe])
&& tokens
.get(probe + 1)
.is_some_and(|next| !opens_clause(next))
{
probe += 1;
if continues_path(tokens[probe].trim_matches(EDGE)) {
end = probe;
}
}
Some(end)
}
fn path_placeholder(first: &str, last: &str) -> String {
let rest = first.trim_start_matches(EDGE);
let mut lead = &first[..first.len() - rest.len()];
if rest.starts_with(['/', '\\']) {
lead = lead.trim_end_matches('.');
}
let trail = &last[last.trim_end_matches(EDGE).len()..];
format!("{lead}‹path›{trail}")
}
fn scrub_token(token: &str) -> String {
if matches!(token, "—" | "–") {
return "-".to_string();
}
let core = token.trim_matches(EDGE);
if core.is_empty() {
return token.to_string();
}
let start = token.find(core).unwrap_or(0);
let (lead, trail) = (&token[..start], &token[start + core.len()..]);
let replaced = if core.starts_with('‹') && core.ends_with('›') {
core.to_string()
} else if !core.is_ascii() {
"‹text›".to_string()
} else if core.contains("://") || core.starts_with("www.") {
"‹url›".to_string()
} else if core.contains('@') && core.contains('.') {
"‹email›".to_string()
} else if is_known_identifier(core) {
core.to_string()
} else if starts_path(core) || is_file_name(core) {
"‹path›".to_string()
} else if core.bytes().any(|b| b.is_ascii_digit()) {
"‹n›".to_string()
} else if is_identifier_like(core) {
"‹sym›".to_string()
} else if core
.bytes()
.all(|b| b.is_ascii_alphabetic() || b == b'-' || b == b'\'')
{
if is_own_word(core) {
core.to_string()
} else {
"‹text›".to_string()
}
} else if core.contains('/') || core.contains('\\') {
let own = core
.split(['/', '\\'])
.all(|part| !part.is_empty() && is_own_word(part));
if own {
core.to_string()
} else {
"‹path›".to_string()
}
} else {
"‹sym›".to_string()
};
let lead = if replaced == "‹path›" && core.starts_with(['/', '\\']) {
lead.trim_end_matches('.')
} else {
lead
};
format!("{lead}{replaced}{trail}")
}
pub fn message_template(message: &str) -> Option<String> {
let line = message
.lines()
.map(str::trim)
.find(|line| !line.is_empty())?;
let line: String = line.chars().take(MAX_INPUT_CHARS).collect();
let unquoted = quoted().replace_all(&line, " ‹str› ");
let tokens: Vec<&str> = unquoted.split_whitespace().collect();
let mut words: Vec<String> = Vec::new();
let mut index = 0;
while index < tokens.len() {
let scrubbed = if let Some(end) = path_span(&tokens, index) {
let placeholder = path_placeholder(tokens[index], tokens[end]);
index = end + 1;
placeholder
} else {
index += 1;
scrub_token(tokens[index - 1])
};
if scrubbed.starts_with('‹') && words.last() == Some(&scrubbed) {
continue;
}
words.push(scrubbed);
}
let mut template = words.join(" ");
if template.chars().count() > MAX_TEMPLATE_CHARS {
let cut: String = template.chars().take(MAX_TEMPLATE_CHARS - 1).collect();
template = match cut.rfind(' ') {
Some(space) if space > MAX_TEMPLATE_CHARS / 2 => format!("{}…", &cut[..space]),
_ => format!("{cut}…"),
};
}
let safe = template
.chars()
.all(|c| c.is_ascii_alphanumeric() || " .,:;()[]{}<>!?'-_/‹›…".contains(c));
let words = template
.split_whitespace()
.filter(|word| {
word.chars().filter(char::is_ascii_alphabetic).count() >= 2 && !word.contains('‹')
})
.count();
(safe && words >= 2).then_some(template)
}
#[cfg(test)]
#[path = "failure_template_tests.rs"]
mod tests;