lean_ctx/core/
failure_template.rs1use std::sync::OnceLock;
23
24use regex::Regex;
25
26#[path = "failure_vocabulary.rs"]
27mod vocabulary;
28
29pub const MAX_TEMPLATE_CHARS: usize = 160;
31const MAX_INPUT_CHARS: usize = 400;
33
34const KNOWN_IDENTIFIERS: &[&str] = &[
36 "old_string",
37 "new_string",
38 "replace_all",
39 "file_path",
40 "start_line",
41 "end_line",
42 "max_results",
43 "dry_run",
44 "old_text",
45 "new_text",
46 "replace_unique",
47 "set_line",
48 "replace_lines",
49 "insert_after",
50 "replace_symbol",
51 "timeout_ms",
52 "run_in_background",
53 "job_id",
54 "doc_id",
55 "if_version",
56];
57
58const EDGE: &[char] = &[
60 '(', ')', '[', ']', '{', '}', ',', ';', ':', '.', '!', '?', '<', '>',
61];
62
63fn quoted() -> &'static Regex {
64 static QUOTED: OnceLock<Regex> = OnceLock::new();
65 QUOTED.get_or_init(|| {
66 Regex::new(r#""[^"\n]{0,300}"|`[^`\n]{0,300}`|'[^'\s][^'\n]{0,300}'|«[^»\n]{0,300}»"#)
67 .expect("static regex")
68 })
69}
70
71fn is_known_identifier(token: &str) -> bool {
72 token.starts_with("ctx_")
73 && token[4..]
74 .bytes()
75 .all(|b| b.is_ascii_lowercase() || b == b'_')
76 || KNOWN_IDENTIFIERS.contains(&token)
77}
78
79fn is_vocabulary_word(word: &str) -> bool {
80 let lower = word.to_ascii_lowercase();
81 vocabulary::VOCABULARY
82 .binary_search(&lower.as_str())
83 .is_ok()
84}
85
86fn is_own_word(word: &str) -> bool {
89 is_vocabulary_word(word)
90 || word.strip_suffix("'s").is_some_and(is_vocabulary_word)
91 || (word.contains('-')
92 && word
93 .split('-')
94 .all(|part| !part.is_empty() && is_vocabulary_word(part)))
95}
96
97fn starts_path(core: &str) -> bool {
102 if core.contains("://") {
103 return false;
104 }
105 let bytes = core.as_bytes();
106 let drive = bytes.len() >= 3
107 && bytes[0].is_ascii_alphabetic()
108 && bytes[1] == b':'
109 && matches!(bytes[2], b'\\' | b'/');
110 (core.len() > 1 && core.starts_with('/'))
111 || core.starts_with('~')
112 || ["./", "../", ".\\", "..\\", "\\\\"]
113 .iter()
114 .any(|prefix| core.starts_with(prefix))
115 || drive
116 || ((core.contains('/') || core.contains('\\')) && core.contains('.'))
117}
118
119fn is_file_name(core: &str) -> bool {
121 match core.rsplit_once('.') {
122 Some((stem, ext)) => {
123 !stem.is_empty()
124 && (1..=8).contains(&ext.len())
125 && ext.bytes().all(|b| b.is_ascii_alphanumeric())
126 && ext.bytes().any(|b| b.is_ascii_alphabetic())
127 }
128 None => false,
129 }
130}
131
132fn is_identifier_like(token: &str) -> bool {
133 let has_inner_upper = token.chars().skip(1).any(|c| c.is_ascii_uppercase());
134 let has_lower = token.chars().any(|c| c.is_ascii_lowercase());
135 token.contains("::")
136 || token.contains('_')
137 || token.contains('$')
138 || token.contains('#')
139 || token.contains('=')
140 || (has_inner_upper && has_lower)
141 || token.len() > 24
142}
143
144fn closes_path(token: &str) -> bool {
147 token.ends_with([':', ',', ';', '.', ')', ']', '!', '?'])
148}
149
150fn opens_clause(token: &str) -> bool {
153 token.starts_with(['(', '[', '{', '<', '‹'])
154 || matches!(token, "—" | "–" | "-" | "--" | "->" | "=>" | "|")
155}
156
157fn continues_path(core: &str) -> bool {
160 is_file_name(core)
161 || ((core.contains('/') || core.contains('\\'))
162 && !core
163 .split(['/', '\\'])
164 .all(|part| !part.is_empty() && is_own_word(part)))
165}
166
167fn path_span(tokens: &[&str], start: usize) -> Option<usize> {
175 let core = tokens[start].trim_matches(EDGE);
176 if core.starts_with('‹') || !starts_path(core) {
177 return None;
178 }
179 let mut end = start;
180 let mut probe = start;
181 while !closes_path(tokens[probe])
182 && tokens
183 .get(probe + 1)
184 .is_some_and(|next| !opens_clause(next))
185 {
186 probe += 1;
187 if continues_path(tokens[probe].trim_matches(EDGE)) {
188 end = probe;
189 }
190 }
191 Some(end)
192}
193
194fn path_placeholder(first: &str, last: &str) -> String {
198 let rest = first.trim_start_matches(EDGE);
199 let mut lead = &first[..first.len() - rest.len()];
200 if rest.starts_with(['/', '\\']) {
201 lead = lead.trim_end_matches('.');
202 }
203 let trail = &last[last.trim_end_matches(EDGE).len()..];
204 format!("{lead}‹path›{trail}")
205}
206
207fn scrub_token(token: &str) -> String {
209 if matches!(token, "—" | "–") {
211 return "-".to_string();
212 }
213 let core = token.trim_matches(EDGE);
214 if core.is_empty() {
215 return token.to_string();
216 }
217 let start = token.find(core).unwrap_or(0);
218 let (lead, trail) = (&token[..start], &token[start + core.len()..]);
219 let replaced = if core.starts_with('‹') && core.ends_with('›') {
220 core.to_string()
221 } else if !core.is_ascii() {
222 "‹text›".to_string()
223 } else if core.contains("://") || core.starts_with("www.") {
224 "‹url›".to_string()
225 } else if core.contains('@') && core.contains('.') {
226 "‹email›".to_string()
227 } else if is_known_identifier(core) {
228 core.to_string()
229 } else if starts_path(core) || is_file_name(core) {
230 "‹path›".to_string()
231 } else if core.bytes().any(|b| b.is_ascii_digit()) {
232 "‹n›".to_string()
233 } else if is_identifier_like(core) {
234 "‹sym›".to_string()
235 } else if core
236 .bytes()
237 .all(|b| b.is_ascii_alphabetic() || b == b'-' || b == b'\'')
238 {
239 if is_own_word(core) {
240 core.to_string()
241 } else {
242 "‹text›".to_string()
243 }
244 } else if core.contains('/') || core.contains('\\') {
245 let own = core
247 .split(['/', '\\'])
248 .all(|part| !part.is_empty() && is_own_word(part));
249 if own {
250 core.to_string()
251 } else {
252 "‹path›".to_string()
253 }
254 } else {
255 "‹sym›".to_string()
256 };
257 let lead = if replaced == "‹path›" && core.starts_with(['/', '\\']) {
259 lead.trim_end_matches('.')
260 } else {
261 lead
262 };
263 format!("{lead}{replaced}{trail}")
264}
265
266pub fn message_template(message: &str) -> Option<String> {
269 let line = message
270 .lines()
271 .map(str::trim)
272 .find(|line| !line.is_empty())?;
273 let line: String = line.chars().take(MAX_INPUT_CHARS).collect();
274 let unquoted = quoted().replace_all(&line, " ‹str› ");
275 let tokens: Vec<&str> = unquoted.split_whitespace().collect();
276 let mut words: Vec<String> = Vec::new();
277 let mut index = 0;
278 while index < tokens.len() {
279 let scrubbed = if let Some(end) = path_span(&tokens, index) {
280 let placeholder = path_placeholder(tokens[index], tokens[end]);
281 index = end + 1;
282 placeholder
283 } else {
284 index += 1;
285 scrub_token(tokens[index - 1])
286 };
287 if scrubbed.starts_with('‹') && words.last() == Some(&scrubbed) {
289 continue;
290 }
291 words.push(scrubbed);
292 }
293 let mut template = words.join(" ");
294 if template.chars().count() > MAX_TEMPLATE_CHARS {
295 let cut: String = template.chars().take(MAX_TEMPLATE_CHARS - 1).collect();
296 template = match cut.rfind(' ') {
297 Some(space) if space > MAX_TEMPLATE_CHARS / 2 => format!("{}…", &cut[..space]),
298 _ => format!("{cut}…"),
299 };
300 }
301 let safe = template
302 .chars()
303 .all(|c| c.is_ascii_alphanumeric() || " .,:;()[]{}<>!?'-_/‹›…".contains(c));
304 let words = template
305 .split_whitespace()
306 .filter(|word| {
307 word.chars().filter(char::is_ascii_alphabetic).count() >= 2 && !word.contains('‹')
308 })
309 .count();
310 (safe && words >= 2).then_some(template)
311}
312
313#[cfg(test)]
314#[path = "failure_template_tests.rs"]
315mod tests;