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
65pub 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}