trail/pathfmt.rs
1//! Rendering filesystem paths for the two audiences Trail has: the operating
2//! system, and the person reading the screen.
3//!
4//! `std::fs::canonicalize` returns every absolute Windows path in
5//! *extended-length* (verbatim) form — `\\?\C:\Users\me`, not `C:\Users\me`.
6//! That form is what Win32 wants when a path may be longer than `MAX_PATH`,
7//! and it is not what anyone types, reads, or pastes back into a shell. Trail
8//! canonicalizes its current directory at startup, so without this module the
9//! prefix leaks into the nav panel title, the status bar, `ya` (yank absolute
10//! path), the `--cwd-file` handoff and the `trail --paths` report.
11//!
12//! # The two operations are not the same
13//!
14//! [`display`] renders a path for a human and always drops the prefix. The
15//! result is text that is never handed back to the filesystem, so dropping it
16//! cannot break anything.
17//!
18//! [`simplified`] rewrites a path Trail keeps using, so it drops the prefix
19//! only when the plain spelling addresses the very same file. A path past
20//! `MAX_PATH`, and a name only the verbatim form can express — a reserved DOS
21//! device name, a component ending in a dot or a space, a component holding a
22//! character Win32 reads as a separator or a wildcard — keeps the prefix and
23//! stays correct.
24//!
25//! On every non-Windows platform [`simplified`] is the identity: a backslash
26//! is an ordinary character in a Unix file name, so a file really can be named
27//! `\\?\C:\x` and rewriting it would point somewhere else.
28
29use std::path::{Path, PathBuf};
30
31/// The prefix `canonicalize` puts on an extended-length Windows path.
32const VERBATIM_PREFIX: &str = r"\\?\";
33
34/// The prefix on the extended-length form of a UNC network path.
35const VERBATIM_UNC_PREFIX: &str = r"\\?\UNC\";
36
37/// Longest path Win32 accepts without the verbatim prefix, the terminating
38/// NUL included — so 259 usable characters.
39///
40/// Compared against the UTF-8 byte length, which is never smaller than the
41/// UTF-16 length Win32 actually counts, so the check errs towards keeping the
42/// prefix rather than producing a path the API would reject.
43const MAX_PATH: usize = 260;
44
45/// Names Win32 resolves to a DOS device rather than to a file, in any
46/// directory and whatever extension follows.
47const RESERVED_DEVICE_NAMES: [&str; 22] = [
48 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
49 "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
50];
51
52/// Renders `path` the way a person would type it.
53///
54/// Drops the Windows verbatim prefix (`\\?\C:\x` reads as `C:\x`,
55/// `\\?\UNC\host\share` as `\\host\share`) and leaves every other path
56/// untouched. Device paths such as `\\?\Volume{…}`, which have no plain
57/// spelling, keep the prefix — there is nothing shorter that names them.
58///
59/// The result is for display and for the clipboard, never for reopening the
60/// file: use [`simplified`] when the path is going back to the filesystem.
61///
62/// Lossy for a path that is not valid UTF-8, as any rendering must be.
63pub fn display(path: &Path) -> String {
64 let text = path.to_string_lossy();
65 plain_form(&text).unwrap_or_else(|| text.into_owned())
66}
67
68/// Returns `path` with the Windows verbatim prefix dropped when the plain
69/// spelling addresses the same file, and unchanged otherwise.
70///
71/// Use this for a path Trail will keep passing to the filesystem — the
72/// current directory, the entries listed under it, a yanked path the user
73/// will paste into a shell. Unlike [`display`] it declines to rewrite a path
74/// whose plain form would name something else, or nothing at all.
75///
76/// A no-op on non-Windows platforms.
77/// Fits an already-[`display`]ed path into `width` columns by dropping
78/// components from the **front**, marking the cut with `…`.
79///
80/// The opposite end from ordinary text elision, and deliberately: the tail of a
81/// path is where you are, and the head is the part you can usually infer. A
82/// deep path cut from the right leaves `C:\Users\weedo\proj\it\u…`, which says
83/// everything except the one thing being asked.
84///
85/// The cut snaps forward to the next component boundary when there is one in
86/// range, so the result reads as a path (`…\util\trail`) rather than as a
87/// fragment (`…til\trail`). Both separators are honoured, because a path
88/// rendered on Windows contains `\` and one from a Unix box contains `/`.
89///
90/// Returns the path unchanged when it already fits, and an empty string for a
91/// zero width.
92///
93/// ```
94/// use trail::pathfmt::elide_front;
95/// assert_eq!(elide_front("/home/me/project", 40), "/home/me/project");
96/// assert_eq!(elide_front("/home/me/deep/project", 12), "…/project");
97/// ```
98pub fn elide_front(path: &str, width: usize) -> String {
99 if width == 0 {
100 return String::new();
101 }
102 if path.chars().count() <= width {
103 return path.to_owned();
104 }
105
106 // One column is spent on the marker.
107 let keep = width.saturating_sub(1);
108 let tail: String = path
109 .chars()
110 .skip(path.chars().count().saturating_sub(keep))
111 .collect();
112
113 // Snap forward to a component boundary so the result starts with a
114 // separator rather than in the middle of a directory name.
115 let snapped = tail
116 .find(['/', '\\'])
117 .map(|i| &tail[i..])
118 .unwrap_or(tail.as_str());
119
120 format!("…{snapped}")
121}
122
123pub fn simplified(path: &Path) -> PathBuf {
124 if !cfg!(windows) {
125 return path.to_owned();
126 }
127 // A path that is not valid UTF-8 is left alone: the rewrite is a string
128 // operation, and there is nothing here worth a lossy round-trip for.
129 match path
130 .to_str()
131 .and_then(plain_form)
132 .filter(|p| is_safe_plain(p))
133 {
134 Some(plain) => PathBuf::from(plain),
135 None => path.to_owned(),
136 }
137}
138
139/// [`std::fs::canonicalize`] with the Windows verbatim prefix dropped where
140/// [`simplified`] finds it safe to drop.
141///
142/// This is the form Trail stores in `AppState::cwd`, so that everything
143/// derived from it — entry paths, the nav panel title, yanks, the `--cwd-file`
144/// handoff — is readable without each call site stripping the prefix again.
145///
146/// # Errors
147///
148/// Returns whatever `std::fs::canonicalize` returns: the path must exist and
149/// be reachable.
150pub fn canonicalize(path: &Path) -> std::io::Result<PathBuf> {
151 std::fs::canonicalize(path).map(|resolved| simplified(&resolved))
152}
153
154/// Returns the plain spelling of a verbatim path, or `None` when `text` is
155/// not verbatim or names something only the verbatim form can address.
156fn plain_form(text: &str) -> Option<String> {
157 if let Some(rest) = text.strip_prefix(VERBATIM_UNC_PREFIX) {
158 // `\\?\UNC\host\share` is the verbatim spelling of `\\host\share`.
159 if rest.is_empty() {
160 return None;
161 }
162 return Some(format!(r"\\{rest}"));
163 }
164
165 let rest = text.strip_prefix(VERBATIM_PREFIX)?;
166 // Only a drive-letter path also reads as a plain path. `\\?\Volume{…}`
167 // and the other device paths do not, and must keep the prefix.
168 starts_with_drive(rest).then(|| rest.to_owned())
169}
170
171/// True when `text` opens with a drive letter, a colon and a separator.
172fn starts_with_drive(text: &str) -> bool {
173 let mut chars = text.chars();
174 chars.next().is_some_and(|c| c.is_ascii_alphabetic())
175 && chars.next() == Some(':')
176 && chars.next() == Some('\\')
177}
178
179/// True when Win32 resolves `plain` to the same file the verbatim form named.
180fn is_safe_plain(plain: &str) -> bool {
181 plain.len() < MAX_PATH && components_after_root(plain).all(is_literal_component)
182}
183
184/// Yields the components of `plain` past its root — the parts that name files
185/// and directories, and so the parts Win32 would reinterpret.
186///
187/// The root is `C:\` on a drive path and `\\host\share\` on a UNC one; a host
188/// and share name are not file names and are not checked.
189fn components_after_root(plain: &str) -> impl Iterator<Item = &str> {
190 let body = match plain.strip_prefix(r"\\") {
191 Some(unc) => unc.splitn(3, '\\').nth(2).unwrap_or(""),
192 None => plain.get(3..).unwrap_or(""),
193 };
194 body.split('\\').filter(|part| !part.is_empty())
195}
196
197/// True when Win32 reads `component` as the literal file of that name once the
198/// verbatim prefix is gone.
199fn is_literal_component(component: &str) -> bool {
200 // Legal file names under the verbatim prefix; directory traversal without
201 // it.
202 if component == "." || component == ".." {
203 return false;
204 }
205 // Win32 trims a trailing dot or space off a plain path component, so
206 // `report.` would resolve to `report`.
207 if component.ends_with('.') || component.ends_with(' ') {
208 return false;
209 }
210 // Legal in a verbatim component, and not literal in a plain one: `/`
211 // separates, `?` and `*` are wildcards, `:` opens an alternate data
212 // stream, and the rest are redirection syntax Win32 rejects outright.
213 if component.contains(['/', '?', '*', ':', '<', '>', '"', '|']) {
214 return false;
215 }
216 // A reserved device name beats a real file with the same stem.
217 let stem = component.split('.').next().unwrap_or(component);
218 !RESERVED_DEVICE_NAMES
219 .iter()
220 .any(|reserved| stem.eq_ignore_ascii_case(reserved))
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 // The string-level helpers carry the whole rewrite, and are deliberately
228 // platform-independent so they are covered on a Linux CI runner too.
229
230 // ── plain_form ─────────────────────────────────────────────────────────────
231
232 #[test]
233 fn a_verbatim_drive_path_has_a_plain_form() {
234 assert_eq!(
235 plain_form(r"\\?\C:\Users\me\notes.txt").as_deref(),
236 Some(r"C:\Users\me\notes.txt")
237 );
238 }
239
240 #[test]
241 fn a_verbatim_unc_path_folds_back_to_two_backslashes() {
242 assert_eq!(
243 plain_form(r"\\?\UNC\host\share\file.txt").as_deref(),
244 Some(r"\\host\share\file.txt")
245 );
246 }
247
248 #[test]
249 fn a_plain_path_has_no_verbatim_form_to_strip() {
250 assert_eq!(plain_form(r"C:\Users\me"), None);
251 assert_eq!(plain_form("/home/me/notes.txt"), None);
252 }
253
254 #[test]
255 fn a_device_path_keeps_its_prefix() {
256 // `\\?\Volume{…}` names a volume with no drive letter mounted. There
257 // is no shorter spelling, so rewriting it would break the path.
258 assert_eq!(
259 plain_form(r"\\?\Volume{9a1b2c3d-0000-0000-0000-000000000000}\data"),
260 None
261 );
262 assert_eq!(plain_form(r"\\?\pipe\trail"), None);
263 }
264
265 // ── is_safe_plain ──────────────────────────────────────────────────────────
266
267 #[test]
268 fn an_ordinary_path_is_safe_to_simplify() {
269 assert!(is_safe_plain(r"C:\Users\me\proj\trail\src\pathfmt.rs"));
270 assert!(is_safe_plain(r"\\host\share\team\notes.txt"));
271 // A bare root has no components to reinterpret.
272 assert!(is_safe_plain(r"C:\"));
273 }
274
275 #[test]
276 fn a_path_past_max_path_keeps_the_prefix() {
277 let long = format!(r"C:\{}", "a".repeat(MAX_PATH));
278 assert!(!is_safe_plain(&long));
279 }
280
281 #[test]
282 fn a_reserved_device_name_keeps_the_prefix() {
283 // Plain `C:\tmp\nul.txt` opens the NUL device, not the file — the
284 // file is only reachable through the verbatim form.
285 assert!(!is_safe_plain(r"C:\tmp\nul.txt"));
286 assert!(!is_safe_plain(r"C:\tmp\CON"));
287 assert!(!is_safe_plain(r"C:\tmp\com4.log"));
288 // Not reserved: the stem has to match exactly.
289 assert!(is_safe_plain(r"C:\tmp\console.log"));
290 assert!(is_safe_plain(r"C:\tmp\com10"));
291 }
292
293 #[test]
294 fn a_component_win32_would_rewrite_keeps_the_prefix() {
295 // Win32 trims a trailing dot or space; the verbatim form does not.
296 assert!(!is_safe_plain(r"C:\tmp\report."));
297 assert!(!is_safe_plain(r"C:\tmp\report "));
298 // `.` and `..` are literal names under the prefix and traversal
299 // without it.
300 assert!(!is_safe_plain(r"C:\tmp\..\other"));
301 // A verbatim component may contain a slash; a plain one may not.
302 assert!(!is_safe_plain(r"C:\tmp\a/b"));
303 assert!(!is_safe_plain(r"C:\tmp\a:stream"));
304 assert!(!is_safe_plain(r"C:\tmp\wild*card"));
305 }
306
307 #[test]
308 fn a_unc_host_and_share_are_not_checked_as_file_names() {
309 // The host and share sit in the root, not in the components Win32
310 // would reinterpret — a share named `con$` is perfectly addressable.
311 assert!(is_safe_plain(r"\\host\con$\notes.txt"));
312 }
313
314 // ── display ────────────────────────────────────────────────────────────────
315
316 #[test]
317 fn display_drops_the_verbatim_prefix() {
318 assert_eq!(
319 display(Path::new(r"\\?\C:\Users\me")),
320 r"C:\Users\me".to_owned()
321 );
322 assert_eq!(
323 display(Path::new(r"\\?\UNC\host\share")),
324 r"\\host\share".to_owned()
325 );
326 }
327
328 #[test]
329 fn display_leaves_every_other_path_alone() {
330 assert_eq!(display(Path::new("/home/me")), "/home/me".to_owned());
331 assert_eq!(
332 display(Path::new(r"C:\Users\me")),
333 r"C:\Users\me".to_owned()
334 );
335 }
336
337 #[test]
338 fn display_renders_a_long_path_readably() {
339 // `display` is text, never reopened, so readability wins over the
340 // round-trip guarantee `simplified` has to keep.
341 let long = format!(r"\\?\C:\{}", "a".repeat(MAX_PATH));
342 assert!(!display(Path::new(&long)).contains(VERBATIM_PREFIX));
343 }
344
345 // ── simplified ─────────────────────────────────────────────────────────────
346
347 #[test]
348 #[cfg(windows)]
349 fn simplified_drops_a_prefix_it_can_drop() {
350 assert_eq!(
351 simplified(Path::new(r"\\?\C:\Users\me")),
352 PathBuf::from(r"C:\Users\me")
353 );
354 }
355
356 #[test]
357 #[cfg(windows)]
358 fn simplified_keeps_a_prefix_the_path_needs() {
359 let long = format!(r"\\?\C:\{}", "a".repeat(MAX_PATH));
360 assert_eq!(simplified(Path::new(&long)), PathBuf::from(&long));
361 assert_eq!(
362 simplified(Path::new(r"\\?\C:\tmp\nul.txt")),
363 PathBuf::from(r"\\?\C:\tmp\nul.txt")
364 );
365 }
366
367 #[test]
368 #[cfg(not(windows))]
369 fn simplified_is_the_identity_off_windows() {
370 // A Unix file really can be named `\\?\C:\x`; rewriting it would name
371 // a different file.
372 for path in [r"\\?\C:\Users\me", "/home/me/notes.txt"] {
373 assert_eq!(simplified(Path::new(path)), PathBuf::from(path));
374 }
375 }
376
377 #[test]
378 fn canonicalize_resolves_and_leaves_no_verbatim_prefix() {
379 let dir = tempfile::tempdir().expect("tempdir");
380 let resolved = canonicalize(dir.path()).expect("canonicalize");
381 assert!(resolved.is_absolute());
382 assert!(
383 !resolved.to_string_lossy().starts_with(VERBATIM_PREFIX),
384 "a temp directory is short and ordinary — it should simplify: {resolved:?}"
385 );
386 }
387
388 #[test]
389 fn elide_front_leaves_a_path_that_fits() {
390 assert_eq!(elide_front("/home/me/project", 40), "/home/me/project");
391 // Exactly the width is still a fit.
392 assert_eq!(elide_front("/home/me", 8), "/home/me");
393 }
394
395 #[test]
396 fn elide_front_drops_the_head_and_keeps_where_you_are() {
397 // The tail is the answer to "where am I"; the head is inferable.
398 let out = elide_front("/home/me/work/deep/project", 14);
399 assert!(out.starts_with('…'), "the cut must be visible: {out}");
400 assert!(out.ends_with("project"), "the tail must survive: {out}");
401 assert!(out.chars().count() <= 14, "over budget: {out}");
402 }
403
404 #[test]
405 fn elide_front_snaps_to_a_component_boundary() {
406 // Not `…rk/deep`: a path cut mid-component reads as a different name.
407 assert_eq!(elide_front("/home/me/work/deep", 10), "…/deep");
408 }
409
410 #[test]
411 fn elide_front_honours_a_windows_separator() {
412 let out = elide_front(r"C:\Users\weedo\proj\it\util\trail", 16);
413 assert!(out.starts_with('…'));
414 assert!(out.ends_with("trail"), "{out}");
415 assert!(
416 out.contains('\\'),
417 "a Windows path should snap on its own separator: {out}"
418 );
419 assert!(out.chars().count() <= 16, "over budget: {out}");
420 }
421
422 #[test]
423 fn elide_front_of_a_component_with_no_separator_still_fits() {
424 // Nothing to snap to, so it is cut bluntly rather than not at all.
425 let out = elide_front("averylongsinglecomponent", 10);
426 assert!(out.starts_with('…'));
427 assert!(out.chars().count() <= 10, "over budget: {out}");
428 }
429
430 #[test]
431 fn elide_front_of_zero_width_is_empty() {
432 assert_eq!(elide_front("/home/me", 0), "");
433 }
434}