Skip to main content

rpi_tools/
path_utils.rs

1//! Mirrors `packages/agent/src/harness/tools/path-utils.ts` — tool-path
2//! normalization + resolution, including the read tool's fuzzy-existence lookup
3//! of typographic variants the model commonly emits.
4//!
5//! `resolve_tool_path` (write/edit) normalizes unicode spaces + strips a leading
6//! `@`, then delegates to `env.absolute_path`. `resolve_read_tool_path` (read)
7//! additionally tries NFKC-ish/typographic variants (smart quotes, narrow spaces
8//! in AM/PM timestamps, NFD decomposition) and returns the first that actually
9//! exists on disk — falling back to the original resolved path when none do.
10
11use tokio_util::sync::CancellationToken;
12
13use crate::env::ExecutionEnv;
14use crate::error::FileError;
15
16/// Unicode-space characters replaced with a regular ASCII space by
17/// [`normalize_tool_path`]. Same set as `normalizeForFuzzyMatch`'s space pass.
18const UNICODE_SPACES: &[char] = &[
19    '\u{00A0}', '\u{2002}', '\u{2003}', '\u{2004}', '\u{2005}', '\u{2006}', '\u{2007}', '\u{2008}',
20    '\u{2009}', '\u{200A}', '\u{202F}', '\u{205F}', '\u{3000}',
21];
22
23/// The narrow no-break space (U+202F) used in AM/PM timestamp variants.
24const NARROW_NO_BREAK_SPACE: char = '\u{202F}';
25
26/// Replace unicode spaces with ASCII space and strip a leading `@`. Mirrors
27/// `normalizeToolPath`.
28pub fn normalize_tool_path(path: &str) -> String {
29    let mut out: String = path
30        .chars()
31        .map(|c| if UNICODE_SPACES.contains(&c) { ' ' } else { c })
32        .collect();
33    if out.starts_with('@') {
34        out.remove(0);
35    }
36    out
37}
38
39/// Resolve a tool path for write/edit: normalize, then `env.absolute_path`.
40/// Mirrors `resolveToolPath`.
41pub async fn resolve_tool_path(
42    env: &dyn ExecutionEnv,
43    path: &str,
44    cancel: Option<&CancellationToken>,
45) -> Result<String, FileError> {
46    let normalized = normalize_tool_path(path);
47    let abs = env.absolute_path(&normalized, cancel).await?;
48    Ok(abs.to_string_lossy().into_owned())
49}
50
51/// Resolve a read tool path: try the normalized-resolved path first, and if it
52/// doesn't exist, try typographic variants (smart quotes, narrow spaces in
53/// AM/PM timestamps, NFD decomposition) — returning the first that exists.
54/// Falls back to the original resolved path when none exist. Mirrors
55/// `resolveReadToolPath`.
56pub async fn resolve_read_tool_path(
57    env: &dyn ExecutionEnv,
58    path: &str,
59    cancel: Option<&CancellationToken>,
60) -> Result<String, FileError> {
61    let resolved = resolve_tool_path(env, path, cancel).await?;
62
63    // Build the candidate variant list in insertion order. Dedupe via a Vec
64    // (preserve order; small N).
65    let mut variants: Vec<String> = Vec::new();
66    variants.push(resolved.clone());
67    // AM/PM: regular-space AM/PM. → narrow-NBSP AM/PM. (case-insensitive, only
68    // the space immediately before AM/PM).
69    variants.push(replace_am_pm_space(&resolved));
70    // NFD decomposition.
71    variants.push(nfd(&resolved));
72    // ASCII apostrophe → right single quote.
73    variants.push(resolved.replace('\'', "\u{2019}"));
74    // NFD + apostrophe.
75    variants.push(replace_apostrophe(&nfd(&resolved), '\u{2019}'));
76
77    // Dedupe preserving insertion order.
78    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
79    let deduped: Vec<String> = variants
80        .into_iter()
81        .filter(|v| seen.insert(v.clone()))
82        .collect();
83
84    for variant in &deduped {
85        if env.exists(variant, cancel).await? {
86            return Ok(variant.clone());
87        }
88    }
89    // None exist → return the original resolved path (the read will then fail
90    // with not_found, matching TS).
91    Ok(resolved)
92}
93
94/// Replace ` AM.`/` PM.` (case-insensitive) with `<narrow-nbsp>AM.`/`<narrow-nbsp>PM.`.
95/// Mirrors `resolved.replace(/ (AM|PM)\./gi, " $1.")`.
96fn replace_am_pm_space(s: &str) -> String {
97    let bytes = s.as_bytes();
98    let mut out = String::with_capacity(s.len());
99    let mut i = 0;
100    while i < bytes.len() {
101        // Look for b" AM." / b" PM." (ASCII, case-insensitive) at a char boundary.
102        if i + 3 < bytes.len() && bytes[i] == b' ' {
103            let a = bytes[i + 1].to_ascii_lowercase();
104            let m = bytes[i + 2].to_ascii_lowercase();
105            let dot = bytes[i + 3];
106            if (a == b'a' || a == b'p') && m == b'm' && dot == b'.' {
107                // Emit narrow-NBSP + original-case AM/PM + '.'
108                out.push(NARROW_NO_BREAK_SPACE);
109                out.push(bytes[i + 1] as char);
110                out.push(bytes[i + 2] as char);
111                out.push('.');
112                i += 4;
113                continue;
114            }
115        }
116        // Copy one UTF-8 char.
117        let ch_len = utf8_char_len(bytes[i]);
118        let end = std::cmp::min(i + ch_len, bytes.len());
119        if let Ok(slice) = std::str::from_utf8(&bytes[i..end]) {
120            out.push_str(slice);
121        }
122        i = end;
123    }
124    out
125}
126
127fn replace_apostrophe(s: &str, with: char) -> String {
128    s.replace('\'', &with.to_string())
129}
130
131fn nfd(s: &str) -> String {
132    use unicode_normalization::UnicodeNormalization;
133    s.nfd().collect()
134}
135
136fn utf8_char_len(first_byte: u8) -> usize {
137    if first_byte < 0x80 {
138        1
139    } else if first_byte >> 5 == 0b110 {
140        2
141    } else if first_byte >> 4 == 0b1110 {
142        3
143    } else if first_byte >> 3 == 0b11110 {
144        4
145    } else {
146        // Invalid leading byte — consume 1 to make progress.
147        1
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn normalize_strips_at_and_unicode_spaces() {
157        assert_eq!(normalize_tool_path("@/foo/bar"), "/foo/bar");
158        assert_eq!(normalize_tool_path("\u{00A0}foo"), " foo");
159        assert_eq!(normalize_tool_path("a\u{2003}b"), "a b");
160        // No leading @ → unchanged.
161        assert_eq!(normalize_tool_path("foo/bar"), "foo/bar");
162    }
163
164    #[test]
165    fn replace_am_pm_space_replaces_only_before_ampm_dot() {
166        let s = "log 2024-01-01 2 PM.txt";
167        let out = replace_am_pm_space(s);
168        assert_eq!(out, "log 2024-01-01 2\u{202F}PM.txt");
169        // AM case.
170        let s2 = "log 10 AM.json";
171        assert_eq!(replace_am_pm_space(s2), "log 10\u{202F}AM.json");
172        // Non-AM/PM space preserved.
173        let s3 = "a b c.txt";
174        assert_eq!(replace_am_pm_space(s3), "a b c.txt");
175    }
176
177    #[test]
178    fn nfd_decomposes() {
179        // Precomposed é (U+00E9) → e + combining acute (U+0065 U+0301).
180        let precomposed = "café";
181        let decomposed = nfd(precomposed);
182        assert_ne!(precomposed, decomposed);
183        assert_eq!(decomposed.chars().count(), 5); // c a f e + combining
184    }
185
186    #[test]
187    fn apostrophe_to_smart_quote() {
188        let s = "it's a file";
189        let out = replace_apostrophe(s, '\u{2019}');
190        assert_eq!(out, "it\u{2019}s a file");
191    }
192}