Skip to main content

atuin_common/path/
display_rich.rs

1use std::borrow::Cow;
2use std::fmt;
3use std::path::{MAIN_SEPARATOR, MAIN_SEPARATOR_STR, Path};
4
5/// A [`Display`](fmt::Display) adapter for a path with optional enrichments, built via
6/// [`DisplayRichExt::display_rich`].
7///
8/// With no options set, the `Display` output is byte-identical to [`Path::display`]. You can also:
9///
10/// - [`relative_to`](Self::relative_to): if the path is under `base`, render relative to `base`.
11/// - [`tilde`](Self::tilde): if the path is under `home`, render it as `~` + separator + remainder.
12/// - [`trailing_slash`](Self::trailing_slash): the rendered text ends with the platform separator.
13///
14/// Like [`Path::display`], rendering is lossy for non-UTF-8 paths.
15#[derive(Clone, Debug)]
16pub struct RichDisplay<'a> {
17    path: &'a Path,
18    trailing_slash: bool,
19    relative_to: Option<Cow<'a, Path>>,
20    tilde: Option<Cow<'a, Path>>,
21}
22
23impl<'a> RichDisplay<'a> {
24    fn new(path: &'a Path) -> Self {
25        Self {
26            path,
27            trailing_slash: false,
28            relative_to: None,
29            tilde: None,
30        }
31    }
32
33    #[must_use]
34    pub fn trailing_slash(self, enabled: bool) -> Self {
35        Self {
36            trailing_slash: enabled,
37            ..self
38        }
39    }
40
41    /// Render the path relative to `base` when it is under it. Borrows `base`
42    /// for the lifetime of this `RichDisplay`, so no allocation occurs.
43    #[must_use]
44    pub fn relative_to<P: AsRef<Path> + ?Sized>(self, base: &'a P) -> Self {
45        Self {
46            relative_to: Some(Cow::Borrowed(base.as_ref())),
47            ..self
48        }
49    }
50
51    /// Attempt to print it out relative to the current working directory.
52    #[must_use]
53    pub fn relative_to_cwd(self) -> Self {
54        match std::env::current_dir() {
55            Ok(cwd) => Self {
56                relative_to: Some(Cow::Owned(cwd)),
57                ..self
58            },
59            Err(_) => self,
60        }
61    }
62
63    /// Abbreviate the path as `~` + separator + remainder when it is under
64    /// `home`. Borrows `home` for the lifetime of this `RichDisplay`, so no
65    /// allocation occurs.
66    #[must_use]
67    pub fn tilde<P: AsRef<Path> + ?Sized>(self, home: &'a P) -> Self {
68        Self {
69            tilde: Some(Cow::Borrowed(home.as_ref())),
70            ..self
71        }
72    }
73
74    /// Abbreviate the path relative to the current user's home directory.
75    #[must_use]
76    pub fn tilde_me(self) -> Self {
77        // TODO(markovejnovic): Do not call BaseDirs here. It does a lot of work. This is a massive
78        //                      amount of work. We should lazily initialize, but this pattern is
79        //                      spread throughout the code everywhere.
80        match directories::BaseDirs::new() {
81            Some(dirs) => Self {
82                tilde: Some(Cow::Owned(dirs.home_dir().to_owned())),
83                ..self
84            },
85            None => self,
86        }
87    }
88}
89
90impl fmt::Display for RichDisplay<'_> {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        let maybe_write_terminal_slash = |f: &mut fmt::Formatter<'_>, p: &Path| -> fmt::Result {
93            if self.trailing_slash
94                && p.as_os_str().as_encoded_bytes().last() != Some(&(MAIN_SEPARATOR as u8))
95            {
96                write!(f, "{MAIN_SEPARATOR_STR}")?;
97            }
98
99            Ok(())
100        };
101
102        if let Some(base) = self.relative_to.as_deref()
103            && let Ok(stripped) = self.path.strip_prefix(base)
104        {
105            write!(f, "{}", stripped.display())?;
106            maybe_write_terminal_slash(f, stripped)?;
107        } else if let Some(home) = self.tilde.as_deref()
108            && let Ok(stripped) = self.path.strip_prefix(home)
109        {
110            write!(f, "~{}{}", MAIN_SEPARATOR_STR, stripped.display())?;
111            if !stripped.as_os_str().is_empty() {
112                maybe_write_terminal_slash(f, stripped)?;
113            }
114        } else {
115            write!(f, "{}", self.path.display())?;
116            maybe_write_terminal_slash(f, self.path)?;
117        }
118
119        Ok(())
120    }
121}
122
123/// Extension adding [`display_rich`](DisplayRichExt::display_rich) to any
124/// path-like value.
125pub trait DisplayRichExt {
126    /// Returns a [`RichDisplay`] builder for this path.
127    fn display_rich(&self) -> RichDisplay<'_>;
128}
129
130impl<T: AsRef<Path> + ?Sized> DisplayRichExt for T {
131    fn display_rich(&self) -> RichDisplay<'_> {
132        RichDisplay::new(self.as_ref())
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use std::path::{MAIN_SEPARATOR_STR, Path, PathBuf};
139
140    use rstest::rstest;
141
142    use super::DisplayRichExt;
143
144    /// With no options, output is byte-identical to `Path::display()`.
145    #[rstest]
146    #[case(Path::new("relative").join("some").join("dir"))]
147    #[case(Path::new("etc").join("hosts"))]
148    fn base_output_matches_path_display(#[case] path: PathBuf) {
149        assert_eq!(path.display_rich().to_string(), path.display().to_string());
150    }
151
152    /// `trailing_slash(true)` appends the platform separator only when missing;
153    /// `trailing_slash(false)` leaves the base output untouched.
154    #[rstest]
155    #[case::appends_when_missing("foo".to_string(), true, format!("foo{MAIN_SEPARATOR_STR}"))]
156    #[case::idempotent_when_terminated(
157        format!("foo{MAIN_SEPARATOR_STR}"),
158        true,
159        format!("foo{MAIN_SEPARATOR_STR}")
160    )]
161    #[case::disabled_is_base("foo".to_string(), false, "foo".to_string())]
162    #[case::empty_becomes_bare_separator(String::new(), true, MAIN_SEPARATOR_STR.to_string())]
163    fn trailing_slash_rendering(
164        #[case] input: String,
165        #[case] enabled: bool,
166        #[case] expected: String,
167    ) {
168        assert_eq!(input.display_rich().trailing_slash(enabled).to_string(), expected);
169    }
170
171    /// `display_rich()` is available on every `AsRef<Path>` input type.
172    #[rstest]
173    fn works_for_all_asref_path_types() {
174        let expected = format!("bar{MAIN_SEPARATOR_STR}");
175        assert_eq!("bar".display_rich().trailing_slash(true).to_string(), expected);
176        assert_eq!(String::from("bar").display_rich().trailing_slash(true).to_string(), expected);
177        assert_eq!(Path::new("bar").display_rich().trailing_slash(true).to_string(), expected);
178        assert_eq!(PathBuf::from("bar").display_rich().trailing_slash(true).to_string(), expected);
179    }
180
181    /// `relative_to(base)` strips `base` when the path is under it (empty when
182    /// equal), and passes the path through otherwise.
183    #[rstest]
184    #[case::strips_prefix(
185        Path::new("home").join("user").join("project").join("src"),
186        Path::new("home").join("user"),
187        Path::new("project").join("src").display().to_string()
188    )]
189    #[case::base_itself_is_empty(
190        Path::new("home").join("user"),
191        Path::new("home").join("user"),
192        String::new()
193    )]
194    #[case::passes_through_when_not_under_base(
195        Path::new("etc").join("hosts"),
196        Path::new("home").join("user"),
197        Path::new("etc").join("hosts").display().to_string()
198    )]
199    fn relative_to_rendering(
200        #[case] path: PathBuf,
201        #[case] base: PathBuf,
202        #[case] expected: String,
203    ) {
204        assert_eq!(path.display_rich().relative_to(&base).to_string(), expected);
205    }
206
207    /// `tilde(home)` renders `~` + separator + remainder when under `home`
208    /// (`~` + separator for `home` itself), and passes through otherwise.
209    #[rstest]
210    #[case::abbreviates_home(
211        Path::new("home").join("user").join("project"),
212        Path::new("home").join("user"),
213        format!("~{MAIN_SEPARATOR_STR}project")
214    )]
215    #[case::home_root_is_tilde_separator(
216        Path::new("home").join("user"),
217        Path::new("home").join("user"),
218        format!("~{MAIN_SEPARATOR_STR}")
219    )]
220    #[case::passes_through_when_not_under_home(
221        Path::new("etc").join("hosts"),
222        Path::new("home").join("user"),
223        Path::new("etc").join("hosts").display().to_string()
224    )]
225    fn tilde_rendering(#[case] path: PathBuf, #[case] home: PathBuf, #[case] expected: String) {
226        assert_eq!(path.display_rich().tilde(&home).to_string(), expected);
227    }
228
229    /// `relative_to` takes precedence over `tilde` when both match.
230    #[rstest]
231    fn relative_to_takes_priority_over_tilde() {
232        let dir = Path::new("home").join("user");
233        let p = dir.join("proj");
234        assert_eq!(p.display_rich().relative_to(&dir).tilde(&dir).to_string(), "proj");
235    }
236
237    /// Enrichments compose with `trailing_slash`. The `relative_to`-to-base
238    /// case yields an empty body, so `trailing_slash` renders a bare separator
239    /// — pinning that edge, which no current caller reaches.
240    #[rstest]
241    #[case::tilde(
242        Path::new("home").join("user").join("proj"),
243        None,
244        Some(Path::new("home").join("user")),
245        format!("~{MAIN_SEPARATOR_STR}proj{MAIN_SEPARATOR_STR}")
246    )]
247    // The tilde render for `home` itself already ends in a separator (`~/`), so
248    // `trailing_slash` must not add a second one. This exercises the tilde branch,
249    // where the rendered text differs from `self.path`.
250    #[case::tilde_home_root_is_idempotent(
251        Path::new("home").join("user"),
252        None,
253        Some(Path::new("home").join("user")),
254        format!("~{MAIN_SEPARATOR_STR}")
255    )]
256    #[case::relative_to_base_itself(
257        Path::new("home").join("user"),
258        Some(Path::new("home").join("user")),
259        None,
260        MAIN_SEPARATOR_STR.to_string()
261    )]
262    fn composes_with_trailing_slash(
263        #[case] path: PathBuf,
264        #[case] relative_to: Option<PathBuf>,
265        #[case] tilde: Option<PathBuf>,
266        #[case] expected: String,
267    ) {
268        let mut d = path.display_rich();
269        if let Some(base) = &relative_to {
270            d = d.relative_to(base);
271        }
272        if let Some(home) = &tilde {
273            d = d.tilde(home);
274        }
275        assert_eq!(d.trailing_slash(true).to_string(), expected);
276    }
277}