Skip to main content

thndrs_lib/cli/renderer/
path_display.rs

1use std::path::{Component, Path, PathBuf};
2
3use crate::utils;
4
5struct DisplayPathParts {
6    prefix: String,
7    components: Vec<DisplayPathComponent>,
8}
9
10impl DisplayPathParts {
11    fn maybe(path: &Path, home: Option<PathBuf>) -> Option<DisplayPathParts> {
12        let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
13        let (prefix, base, rel) = match home {
14            Some(home) => {
15                let home = home.canonicalize().unwrap_or(home);
16                match path.strip_prefix(&home) {
17                    Ok(rest) => (String::from("~"), home, rest),
18                    Err(_) => {
19                        let root = path.components().next()?;
20                        (
21                            root_prefix(root),
22                            match root {
23                                Component::RootDir => PathBuf::from("/"),
24                                _ => PathBuf::new(),
25                            },
26                            path.as_path(),
27                        )
28                    }
29                }
30            }
31            None => {
32                let root = path.components().next()?;
33                let base = match root {
34                    Component::RootDir => PathBuf::from("/"),
35                    _ => PathBuf::new(),
36                };
37                (root_prefix(root), base, path.as_path())
38            }
39        };
40
41        let mut parent = base;
42        let mut components = Vec::new();
43        for component in rel.components() {
44            let Component::Normal(name) = component else {
45                continue;
46            };
47            components.push(DisplayPathComponent { name: name.to_string_lossy().to_string(), parent: parent.clone() });
48            parent.push(name);
49        }
50
51        Some(DisplayPathParts { prefix, components })
52    }
53}
54
55struct DisplayPathComponent {
56    name: String,
57    parent: PathBuf,
58}
59
60pub fn footer_segment(path: &Path, line_width: usize, used_width: usize) -> String {
61    let budget = line_width.saturating_sub(used_width + 1);
62    cwd_segment(path, budget)
63}
64
65/// Render a `cwd:` segment capped to `max_width` terminal cells.
66pub fn cwd_segment(path: &Path, max_width: usize) -> String {
67    let prefix = "cwd: ";
68    let budget = max_width.saturating_sub(prefix.len());
69    let home = std::env::var_os("HOME").map(PathBuf::from);
70    format!("{prefix}{}", cwd_display_for_width_with_home(path, budget, home))
71}
72
73pub fn transcript_line(line: &str, workspace: &Path) -> String {
74    let home = std::env::var_os("HOME").map(PathBuf::from);
75    transcript_line_with_home(line, workspace, home.as_ref())
76}
77
78fn transcript_line_with_home(line: &str, workspace: &Path, home: Option<&PathBuf>) -> String {
79    let mut out = String::new();
80    let mut last = 0usize;
81    let mut i = 0usize;
82
83    while i < line.len() {
84        let Some(ch) = line[i..].chars().next() else {
85            break;
86        };
87        if is_path_start(line, i) {
88            out.push_str(&line[last..i]);
89            let end = path_token_end(line, i);
90            out.push_str(&shorten_path_token(&line[i..end], workspace, home));
91            i = end;
92            last = end;
93        } else {
94            i += ch.len_utf8();
95        }
96    }
97
98    out.push_str(&line[last..]);
99    out
100}
101
102fn is_path_start(line: &str, idx: usize) -> bool {
103    line[idx..].starts_with('/') || line[idx..].starts_with("~/")
104}
105
106fn path_token_end(line: &str, start: usize) -> usize {
107    let mut end = line.len();
108    for (offset, ch) in line[start..].char_indices() {
109        if offset == 0 {
110            continue;
111        }
112        if is_path_token_boundary(ch) {
113            end = start + offset;
114            break;
115        }
116    }
117    end
118}
119
120fn is_path_token_boundary(ch: char) -> bool {
121    ch.is_whitespace() || matches!(ch, '"' | '\'' | '`' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}')
122}
123
124fn shorten_path_token(token: &str, workspace: &Path, home: Option<&PathBuf>) -> String {
125    let (core, trailing) = split_trailing_punctuation(token);
126    let (path_text, location) = split_location_suffix(core);
127
128    let Some(path) = token_path(path_text, home) else {
129        return token.to_string();
130    };
131
132    let shortened = shorten_absolute_path(&path, workspace, home).unwrap_or_else(|| path_text.to_string());
133    format!("{shortened}{location}{trailing}")
134}
135
136fn split_trailing_punctuation(token: &str) -> (&str, &str) {
137    let Some((idx, ch)) = token.char_indices().next_back() else {
138        return (token, "");
139    };
140    if matches!(ch, ',' | ';') { (&token[..idx], &token[idx..]) } else { (token, "") }
141}
142
143fn split_location_suffix(token: &str) -> (&str, &str) {
144    let last_slash = token.rfind('/').unwrap_or(0);
145    for (idx, ch) in token.char_indices() {
146        if idx <= last_slash || ch != ':' {
147            continue;
148        }
149        let suffix = &token[idx..];
150        if is_location_suffix(suffix) {
151            return (&token[..idx], suffix);
152        }
153    }
154    (token, "")
155}
156
157fn is_location_suffix(suffix: &str) -> bool {
158    let mut chars = suffix.chars().peekable();
159    let mut groups = 0usize;
160
161    while chars.peek().is_some() {
162        if chars.next() != Some(':') {
163            return false;
164        }
165
166        let mut digits = 0usize;
167        while chars.peek().is_some_and(|ch| ch.is_ascii_digit()) {
168            chars.next();
169            digits += 1;
170        }
171        if digits == 0 {
172            return chars.peek().is_none() && groups > 0;
173        }
174        groups += 1;
175    }
176
177    groups > 0
178}
179
180fn token_path(path_text: &str, home: Option<&PathBuf>) -> Option<PathBuf> {
181    if let Some(rest) = path_text.strip_prefix("~/") {
182        return home.map(|home| home.join(rest));
183    }
184    if path_text.starts_with('/') { Some(PathBuf::from(path_text)) } else { None }
185}
186
187fn shorten_absolute_path(path: &Path, workspace: &Path, home: Option<&PathBuf>) -> Option<String> {
188    if let Some(rel) = strip_prefix_canonical_or_raw(path, workspace)
189        && !rel.as_os_str().is_empty()
190    {
191        return Some(rel.display().to_string());
192    }
193
194    if let Some(home) = home
195        && let Some(rel) = strip_prefix_canonical_or_raw(path, home)
196    {
197        return Some(if rel.as_os_str().is_empty() { "~".to_string() } else { format!("~/{}", rel.display()) });
198    }
199
200    None
201}
202
203fn strip_prefix_canonical_or_raw(path: &Path, prefix: &Path) -> Option<PathBuf> {
204    let path_canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
205    let prefix_canon = prefix.canonicalize().unwrap_or_else(|_| prefix.to_path_buf());
206
207    path_canon
208        .strip_prefix(&prefix_canon)
209        .or_else(|_| path.strip_prefix(prefix))
210        .ok()
211        .map(PathBuf::from)
212}
213
214fn cwd_display_for_width_with_home(path: &Path, max_width: usize, home: Option<PathBuf>) -> String {
215    if max_width == 0 {
216        return String::new();
217    }
218
219    let full = home_relative_display(path, home.clone());
220    if utils::text_width(&full) <= max_width {
221        return full;
222    }
223
224    let Some(parts) = DisplayPathParts::maybe(path, home) else {
225        return utils::truncate_ellipsis_start(&full, max_width);
226    };
227    if parts.components.is_empty() {
228        return utils::truncate_ellipsis_start(&full, max_width);
229    }
230
231    let compact_components = parts
232        .components
233        .iter()
234        .enumerate()
235        .map(|(i, part)| {
236            if i + 1 == parts.components.len() {
237                part.name.clone()
238            } else {
239                shortest_unique_prefix(&part.name, &part.parent)
240            }
241        })
242        .collect::<Vec<_>>();
243    let compact = render_path_parts(&parts.prefix, &compact_components);
244    if utils::text_width(&compact) <= max_width {
245        return compact;
246    }
247
248    for start in 1..compact_components.len() {
249        let mut candidate = vec![String::from("…")];
250        candidate.extend(compact_components[start..].iter().cloned());
251        let rendered = render_path_parts(&parts.prefix, &candidate);
252        if utils::text_width(&rendered) <= max_width {
253            return rendered;
254        }
255    }
256
257    utils::truncate_ellipsis_start(
258        compact_components.last().map(String::as_str).unwrap_or(&full),
259        max_width,
260    )
261}
262
263fn home_relative_display(path: &Path, home: Option<PathBuf>) -> String {
264    match home {
265        Some(home) => {
266            let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
267            let home = home.canonicalize().unwrap_or(home);
268            match path.strip_prefix(&home) {
269                Ok(rest) if rest.as_os_str().is_empty() => "~".to_string(),
270                Ok(rest) => format!("~/{}", rest.display()),
271                Err(_) => path.display().to_string(),
272            }
273        }
274        None => path.display().to_string(),
275    }
276}
277
278fn root_prefix(component: Component<'_>) -> String {
279    match component {
280        Component::RootDir => String::from("/"),
281        Component::Prefix(prefix) => prefix.as_os_str().to_string_lossy().to_string(),
282        _ => String::new(),
283    }
284}
285
286fn render_path_parts(prefix: &str, components: &[String]) -> String {
287    if components.is_empty() {
288        prefix.to_string()
289    } else {
290        match prefix {
291            "" => components.join("/"),
292            "/" => format!("/{}", components.join("/")),
293            _ => format!("{prefix}/{}", components.join("/")),
294        }
295    }
296}
297
298fn shortest_unique_prefix(name: &str, parent: &Path) -> String {
299    let chars = name.chars().collect::<Vec<_>>();
300    if chars.len() <= 1 {
301        return name.to_string();
302    }
303
304    let siblings = match std::fs::read_dir(parent) {
305        Ok(entries) => entries
306            .filter_map(Result::ok)
307            .map(|entry| entry.file_name().to_string_lossy().to_string())
308            .collect::<Vec<_>>(),
309        Err(_) => return chars[0].to_string(),
310    };
311
312    for len in 1..chars.len() {
313        let prefix = chars.iter().take(len).collect::<String>();
314        let ambiguous = siblings
315            .iter()
316            .any(|sibling| sibling != name && sibling.starts_with(&prefix));
317        if !ambiguous {
318            return prefix;
319        }
320    }
321
322    name.to_string()
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use std::fs;
329
330    #[test]
331    fn home_relative_display_shortens_paths_under_home() {
332        let path = PathBuf::from("/home/owais/project");
333        let home = PathBuf::from("/home/owais");
334        assert_eq!(home_relative_display(&path, Some(home)), "~/project");
335    }
336
337    #[test]
338    fn home_relative_display_shortens_home_itself() {
339        let path = PathBuf::from("/home/owais");
340        let home = PathBuf::from("/home/owais");
341        assert_eq!(home_relative_display(&path, Some(home)), "~");
342    }
343
344    #[test]
345    fn home_relative_display_leaves_paths_outside_home() {
346        let path = PathBuf::from("/repo");
347        let home = PathBuf::from("/home/owais");
348        assert_eq!(home_relative_display(&path, Some(home)), "/repo");
349    }
350
351    #[test]
352    fn home_relative_display_leaves_paths_when_home_is_missing() {
353        let path = PathBuf::from("/repo");
354        assert_eq!(home_relative_display(&path, None), "/repo");
355    }
356
357    #[test]
358    fn cwd_display_for_width_keeps_full_path_when_it_fits() {
359        let home = PathBuf::from("/home/owais");
360        let path = home.join("Projects/thndrs");
361        assert_eq!(
362            cwd_display_for_width_with_home(&path, 40, Some(home)),
363            "~/Projects/thndrs"
364        );
365    }
366
367    #[test]
368    fn cwd_display_for_width_abbreviates_ancestors_with_unique_prefixes() {
369        let dir = tempfile::tempdir().expect("create temp dir");
370        let home = dir.path().join("home");
371        let path = home.join("Projects/StormlightLabs/OpenSource/thndrs");
372        fs::create_dir_all(&path).expect("mkdir path");
373        fs::create_dir_all(home.join("Pictures")).expect("mkdir sibling");
374
375        assert_eq!(
376            cwd_display_for_width_with_home(&path, 20, Some(home)),
377            "~/Pr/S/O/thndrs"
378        );
379    }
380
381    #[test]
382    fn cwd_display_for_width_keeps_longest_meaningful_suffix() {
383        let dir = tempfile::tempdir().expect("create temp dir");
384        let home = dir.path().join("home");
385        let path = home.join("Projects/StormlightLabs/OpenSource/thndrs");
386        fs::create_dir_all(&path).expect("mkdir path");
387        fs::create_dir_all(home.join("Pictures")).expect("mkdir sibling");
388        assert_eq!(cwd_display_for_width_with_home(&path, 12, Some(home)), "~/…/O/thndrs");
389    }
390
391    #[test]
392    fn cwd_display_for_width_preserves_current_dir_when_tiny() {
393        let path = PathBuf::from("/alpha/beta/thndrs");
394        assert_eq!(cwd_display_for_width_with_home(&path, 4, None), "…drs");
395    }
396
397    #[test]
398    fn transcript_line_drops_workspace_prefix_and_preserves_line_suffix() {
399        let workspace = PathBuf::from("/home/owais/Projects/thndrs");
400        let line = "/home/owais/Projects/thndrs/src/session/tests.rs:1420: Entry::Status";
401        let home = PathBuf::from("/home/owais");
402
403        assert_eq!(
404            transcript_line_with_home(line, &workspace, Some(&home)),
405            "src/session/tests.rs:1420: Entry::Status"
406        );
407    }
408
409    #[test]
410    fn transcript_line_shortens_home_paths_outside_workspace() {
411        let workspace = PathBuf::from("/home/owais/Projects/thndrs");
412        let line = "read /home/owais/.config/thndrs/config.toml";
413        let home = PathBuf::from("/home/owais");
414
415        assert_eq!(
416            transcript_line_with_home(line, &workspace, Some(&home)),
417            "read ~/.config/thndrs/config.toml"
418        );
419    }
420
421    #[test]
422    fn transcript_line_preserves_non_home_absolute_paths() {
423        let workspace = PathBuf::from("/home/owais/Projects/thndrs");
424        let line = "/var/log/system.log:12";
425        let home = PathBuf::from("/home/owais");
426
427        assert_eq!(
428            transcript_line_with_home(line, &workspace, Some(&home)),
429            "/var/log/system.log:12"
430        );
431    }
432}