Skip to main content

lean_ctx/core/
failure_template.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Turns a tool's error message into a template that is safe to report.
4//!
5//! The approach follows crash-reporting scrubbers (Sentry's PII scrubbing,
6//! VS Code's telemetry path cleaning), but is stricter: instead of removing
7//! what looks sensitive, only LeanCTX's own static wording survives. A word
8//! is kept only when it is in `vocabulary::VOCABULARY`, the words LeanCTX's
9//! error messages are written in; everything else becomes a placeholder:
10//!
11//! - quoted text → `‹str›`, URLs → `‹url›`, e-mail addresses → `‹email›`
12//! - a path, from its first segment through its end, spaces included, and
13//!   file names → `‹path›`
14//! - numbers and anything containing a digit → `‹n›`
15//! - identifiers (camelCase, snake_case, `::`, long tokens) → `‹sym›`,
16//!   except LeanCTX's own tool and parameter names
17//! - any other word (not LeanCTX's wording, or not ASCII) → `‹text›`
18//!
19//! A template without plain words, or with characters outside a small safe
20//! set, is dropped entirely; only the failure class is reported then.
21
22use std::sync::OnceLock;
23
24use regex::Regex;
25
26#[path = "failure_vocabulary.rs"]
27mod vocabulary;
28
29/// Longest template that is reported.
30pub const MAX_TEMPLATE_CHARS: usize = 160;
31/// Input beyond this is not considered (the first line is what matters).
32const MAX_INPUT_CHARS: usize = 400;
33
34/// LeanCTX's own parameter names: kept verbatim because they explain errors.
35const 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
58/// Punctuation that frames a token without being part of it.
59const 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
86/// A plain word of LeanCTX's own wording: the word itself, its possessive
87/// (`file's`), or a hyphenated compound of such words (`read-only`).
88fn 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
97/// Unmistakably the start of a file-system path: absolute (`/`), home (`~`),
98/// explicitly relative (`./`, `../`), a drive letter (`C:\`) or UNC share, or
99/// separated segments with a dot (`src/main.rs`). Words joined by a slash
100/// (`whitespace/indentation`) are not.
101fn 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
119/// `file.ext` with a short alphanumeric extension: main.rs, Cargo.toml.
120fn 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
144/// A path that reached `token` ends with it: the token closes a clause
145/// (`: `, `, `, `; `, `. `, a closing bracket).
146fn closes_path(token: &str) -> bool {
147    token.ends_with([':', ',', ';', '.', ')', ']', '!', '?'])
148}
149
150/// The token after a path opens a new clause (` (`, ` [`, a placeholder, a
151/// dash or an arrow), so the path ended before it.
152fn opens_clause(token: &str) -> bool {
153    token.starts_with(['(', '[', '{', '<', '‹'])
154        || matches!(token, "—" | "–" | "-" | "--" | "->" | "=>" | "|")
155}
156
157/// A later part of a path with spaces in it: a segment with a separator that
158/// is not LeanCTX's wording (`Notes/med`), or a file name (`list.md`).
159fn 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
167/// The index of the last token of a path that starts at `tokens[start]`, or
168/// `None` when no path starts there. Within the clause the path starts in
169/// (up to `: `, `, `, `. `, ` (` or the end of the line), the path runs
170/// through spaces to its last segment or file name, so `/Users/x/My Health
171/// Notes/med list.md is outside` ends at `list.md` and keeps the wording after
172/// it. Words of a path that are left over (`My Notes` in `/x/My Notes`) are
173/// not LeanCTX's wording and become placeholders of their own.
174fn 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
194/// `‹path›` framed by the opening punctuation of the path's first token and
195/// the closing punctuation of its last one. Dots right before a separator
196/// (`./`, `../`) belong to the path, not to the punctuation.
197fn 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
207/// Classify one whitespace-separated token, keeping its outer punctuation.
208fn scrub_token(token: &str) -> String {
209    // A dash between clauses is punctuation, not a word.
210    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        // `whitespace/indentation` is wording; `docs/records` is a path.
246        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    // `./x` and `../x`: the dots are part of the path, not punctuation.
258    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
266/// The reportable template of an error message, or `None` when nothing safe
267/// and meaningful is left.
268pub 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        // Collapse runs of the same placeholder ("‹n› ‹n›" → "‹n›").
288        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;