Skip to main content

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}