1use std::borrow::Cow;
2use std::fmt;
3use std::path::{MAIN_SEPARATOR, MAIN_SEPARATOR_STR, Path};
4
5#[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 #[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 #[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 #[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 #[must_use]
76 pub fn tilde_me(self) -> Self {
77 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
123pub trait DisplayRichExt {
126 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}