1use tokio_util::sync::CancellationToken;
12
13use crate::env::ExecutionEnv;
14use crate::error::FileError;
15
16const 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
23const NARROW_NO_BREAK_SPACE: char = '\u{202F}';
25
26pub 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
39pub 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
51pub 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 let mut variants: Vec<String> = Vec::new();
66 variants.push(resolved.clone());
67 variants.push(replace_am_pm_space(&resolved));
70 variants.push(nfd(&resolved));
72 variants.push(resolved.replace('\'', "\u{2019}"));
74 variants.push(replace_apostrophe(&nfd(&resolved), '\u{2019}'));
76
77 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 Ok(resolved)
92}
93
94fn 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 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 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 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 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 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 let s2 = "log 10 AM.json";
171 assert_eq!(replace_am_pm_space(s2), "log 10\u{202F}AM.json");
172 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 let precomposed = "café";
181 let decomposed = nfd(precomposed);
182 assert_ne!(precomposed, decomposed);
183 assert_eq!(decomposed.chars().count(), 5); }
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}