Skip to main content

lattice_core/
home.rs

1//! `~` expansion, in one place.
2//!
3//! ## Why this exists rather than a local helper per consumer
4//!
5//! There were four, and they disagreed in the way that matters. The agenda's,
6//! the completion generator's, the dispatcher's and the org guest's were each a
7//! private `expand_tilde`, and the first three resolved the home directory as
8//! `std::env::var_os("HOME")` — which is POSIX-only. On Windows that returns
9//! `None`, so `~/notes` was left verbatim and every lookup silently found
10//! nothing. `org.agenda-files`' own documentation says "`~` is expanded", and
11//! on Windows it did not.
12//!
13//! Silent is the operative word. A path that fails to expand does not error; it
14//! becomes a path that does not exist, and every consumer here reports "no
15//! files" rather than "that is not a real directory". The user is then told
16//! their corpus is empty.
17//!
18//! [`dirs::home_dir`] answers on Windows too (`%USERPROFILE%`, then the
19//! known-folder API), which is the whole reason to converge rather than to fix
20//! four copies of the `HOME` lookup.
21//!
22//! ## What it deliberately is not
23//!
24//! Not shell expansion. No `$VAR`, no `~other-user`, no globbing. The one thing
25//! a user types by hand that a `PathBuf` will not resolve is a leading tilde,
26//! and every step past that is a parser with its own quoting rules — which a
27//! config value is not asking for. A `$HOME/notes` that stayed verbatim would
28//! be visible and fixable; guessing at it would not.
29
30use std::path::{Path, PathBuf};
31
32/// Expand a leading `~` against the user's home directory.
33///
34/// `~` alone and `~/rest` both expand; `~user` does not (there is no lookup for
35/// another user's home that works across platforms). Anything else is returned
36/// unchanged, so this is safe to call on a path that is already absolute.
37///
38/// Unresolvable home → the input, verbatim. That keeps the failure visible as a
39/// path with a `~` still in it rather than as a silently-wrong location.
40pub fn expand_tilde(raw: &str) -> String {
41    let Some(rest) = raw.strip_prefix('~') else {
42        return raw.to_string();
43    };
44    // `~user` is not ours to resolve, and mangling it into `<home>user` would
45    // be worse than leaving it: the result is a plausible path to the wrong
46    // place, which is exactly the failure this module exists to end.
47    if !rest.is_empty() && !rest.starts_with('/') && !rest.starts_with('\\') {
48        return raw.to_string();
49    }
50    let Some(home) = dirs::home_dir() else {
51        return raw.to_string();
52    };
53    let rest = rest.trim_start_matches(['/', '\\']);
54    if rest.is_empty() {
55        return home.display().to_string();
56    }
57    home.join(rest).display().to_string()
58}
59
60/// [`expand_tilde`] for a `Path`, returning a `PathBuf`.
61pub fn expand_tilde_path(raw: &Path) -> PathBuf {
62    match raw.to_str() {
63        // Non-UTF-8 cannot contain a leading ASCII `~` we could act on without
64        // re-encoding, and a path is never worth mangling to expand one.
65        None => raw.to_path_buf(),
66        Some(s) => PathBuf::from(expand_tilde(s)),
67    }
68}
69
70/// [`expand_tilde`]'s inverse: put the `~` back for DISPLAY.
71///
72/// `/Users/dhruva/src/lattice` → `~/src/lattice`. For showing a path in a
73/// prompt, a status line or any other one-line surface where the home prefix is
74/// the least informative part of it and the part that squeezes out the rest.
75///
76/// **Display only.** The result is not a path to hand to anything that opens
77/// files — that is what the expanding direction is for, and round-tripping
78/// through here would be a way to lose a path whose home resolution changed
79/// underneath it.
80///
81/// Home itself contracts to `~`, not `~/`. Anything outside home, a
82/// non-UTF-8 path, or an unresolvable home is returned verbatim, so the
83/// failure is a path that reads slightly long rather than one that reads wrong.
84pub fn contract_tilde(raw: &Path) -> String {
85    // Windows only: `canonicalize` returns the verbatim form,
86    // `\\?\C:\Users\me\src`, and `home_dir` the ordinary one, so the two never
87    // share a prefix and a canonical path under home was shown in full —
88    // `\\?\` and all. Drop the marker first; this is display, and nobody reads
89    // it.
90    let shown;
91    let raw = match raw.to_str().and_then(without_verbatim_prefix) {
92        Some(plain) if cfg!(windows) => {
93            shown = PathBuf::from(plain);
94            shown.as_path()
95        }
96        _ => raw,
97    };
98    let Some(home) = dirs::home_dir() else {
99        return raw.display().to_string();
100    };
101    match raw.strip_prefix(&home) {
102        Ok(rest) if rest.as_os_str().is_empty() => "~".to_string(),
103        // `Path::join` rather than string concatenation so the separator is the
104        // platform's. `strip_prefix` is component-wise, so `/Users/dhruvax`
105        // cannot match a home of `/Users/dhruva` the way a `starts_with` on the
106        // string would.
107        Ok(rest) => Path::new("~").join(rest).display().to_string(),
108        Err(_) => raw.display().to_string(),
109    }
110}
111
112/// `\\?\C:\x` → `C:\x`. `None` when there is no verbatim marker, and for the
113/// UNC form (`\\?\UNC\server\share`), whose plain spelling is `\\server\share`
114/// rather than what follows the marker.
115///
116/// Pure and unconditional so it is tested on every platform; only
117/// [`contract_tilde`] decides it applies, and only on Windows.
118fn without_verbatim_prefix(path: &str) -> Option<&str> {
119    let rest = path.strip_prefix(r"\\?\")?;
120    (!rest.starts_with(r"UNC\")).then_some(rest)
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn the_verbatim_marker_is_dropped_but_a_unc_path_is_left_alone() {
129        assert_eq!(
130            without_verbatim_prefix(r"\\?\C:\Users\me\src"),
131            Some(r"C:\Users\me\src")
132        );
133        assert_eq!(without_verbatim_prefix(r"C:\Users\me"), None);
134        assert_eq!(without_verbatim_prefix("/home/me"), None);
135        assert_eq!(without_verbatim_prefix(r"\\?\UNC\server\share"), None);
136    }
137
138    #[test]
139    fn a_path_without_a_tilde_is_untouched() {
140        assert_eq!(expand_tilde("/abs/path"), "/abs/path");
141        assert_eq!(expand_tilde("relative/path"), "relative/path");
142        assert_eq!(expand_tilde(""), "");
143        assert_eq!(expand_tilde("a~b"), "a~b", "a tilde must LEAD to count");
144    }
145
146    #[test]
147    fn a_leading_tilde_becomes_the_home_directory() {
148        let Some(home) = dirs::home_dir() else {
149            eprintln!("SKIP: no home directory on this machine");
150            return;
151        };
152        assert_eq!(expand_tilde("~"), home.display().to_string());
153        assert_eq!(
154            expand_tilde("~/notes"),
155            home.join("notes").display().to_string()
156        );
157    }
158
159    /// `~user` is left alone. Expanding it against OUR home would produce a
160    /// plausible path to the wrong place, which is worse than not expanding.
161    #[test]
162    fn another_users_home_is_left_verbatim() {
163        assert_eq!(expand_tilde("~alice/notes"), "~alice/notes");
164        assert_eq!(expand_tilde("~alice"), "~alice");
165    }
166
167    /// No `$VAR`, deliberately — a config value is not a shell command line,
168    /// and a half-implemented expansion is worse than none.
169    #[test]
170    fn shell_variables_are_not_expanded() {
171        assert_eq!(expand_tilde("$HOME/notes"), "$HOME/notes");
172        assert_eq!(expand_tilde("~/$FOO"), {
173            let home = dirs::home_dir().map(|h| h.join("$FOO").display().to_string());
174            home.unwrap_or_else(|| "~/$FOO".to_string())
175        });
176    }
177
178    /// The display direction, and the round trip that says the two agree.
179    #[test]
180    fn a_path_under_home_contracts_back_to_a_tilde() {
181        let Some(home) = dirs::home_dir() else {
182            eprintln!("SKIP: no home directory on this machine");
183            return;
184        };
185        assert_eq!(contract_tilde(&home.join("src").join("lattice")), {
186            let sep = std::path::MAIN_SEPARATOR;
187            format!("~{sep}src{sep}lattice")
188        });
189        assert_eq!(contract_tilde(&home), "~", "home itself is `~`, not `~/`");
190        assert_eq!(
191            expand_tilde(&contract_tilde(&home.join("src"))),
192            home.join("src").display().to_string(),
193            "the two directions round-trip"
194        );
195    }
196
197    /// Outside home is left alone — contracting it would have to invent a
198    /// prefix, and a shortened path to the wrong place is the failure the
199    /// expanding direction already refuses to produce.
200    #[test]
201    fn a_path_outside_home_is_left_verbatim() {
202        assert_eq!(contract_tilde(Path::new("/etc/hosts")), "/etc/hosts");
203    }
204
205    /// Component-wise, not a string prefix. A sibling whose name merely starts
206    /// with the home directory's must not contract — the result would name a
207    /// directory that does not exist.
208    #[test]
209    fn a_sibling_sharing_homes_prefix_does_not_contract() {
210        let Some(home) = dirs::home_dir() else {
211            return;
212        };
213        let Some(name) = home.file_name().and_then(|n| n.to_str()) else {
214            return;
215        };
216        let Some(parent) = home.parent() else {
217            return;
218        };
219        let sibling = parent.join(format!("{name}x"));
220        assert_eq!(contract_tilde(&sibling), sibling.display().to_string());
221    }
222
223    #[test]
224    fn the_path_form_agrees_with_the_string_form() {
225        let Some(home) = dirs::home_dir() else {
226            return;
227        };
228        assert_eq!(expand_tilde_path(Path::new("~/x")), home.join("x"));
229        assert_eq!(expand_tilde_path(Path::new("/x")), PathBuf::from("/x"));
230    }
231}