1use std::borrow::Cow;
2use std::ffi::OsStr;
3use std::path::{Component, Path};
4
5use ratatui::style::Style;
6use ratatui::text::{Line, Span};
7use unicode_width::UnicodeWidthStr;
8
9#[derive(Clone, Copy)]
10pub struct StyleConfig {
11 pub filename: Style,
12 pub path: Style,
13 pub dim: Style,
14 pub separator: Style,
15}
16
17impl Default for StyleConfig {
18 fn default() -> Self {
19 Self {
20 filename: Style::default().add_modifier(ratatui::style::Modifier::BOLD),
21 path: Style::default(),
22 dim: Style::default().add_modifier(ratatui::style::Modifier::DIM),
23 separator: Style::default().add_modifier(ratatui::style::Modifier::DIM),
24 }
25 }
26}
27
28pub fn format_path_line<'a>(path: &'a Path, max_width: usize) -> Line<'a> {
29 let spans = format_path_spans(path, max_width, StyleConfig::default());
30 Line::from(spans)
31}
32
33pub fn format_path_line_with_style<'a>(
34 path: &'a Path,
35 max_width: usize,
36 style: StyleConfig,
37) -> Line<'a> {
38 let spans = format_path_spans(path, max_width, style);
39 Line::from(spans)
40}
41
42pub fn format_path_spans(path: &Path, max_width: usize, style: StyleConfig) -> Vec<Span<'static>> {
44 format_path_spans_with_min_tail(path, max_width, style, 1)
45}
46
47pub fn format_path_spans_with_min_tail(
51 path: &Path,
52 max_width: usize,
53 style: StyleConfig,
54 min_tail: usize,
55) -> Vec<Span<'static>> {
56 let (filename, dirs) = split_path(path);
57
58 let filename_str = os_to_str(filename).into_owned();
59 let file_w = width(&filename_str);
60
61 if dirs.is_empty() {
63 return vec![Span::styled(filename_str, style.filename)];
64 }
65
66 let sep = " — ";
67 let sep_w = width(sep);
68
69 if file_w + sep_w >= max_width {
70 return vec![Span::styled(filename_str, style.filename)];
71 }
72
73 let mut remaining = max_width - file_w - sep_w;
74
75 let mut spans: Vec<Span<'static>> = Vec::with_capacity(8);
76 spans.push(Span::styled(filename_str.clone(), style.filename));
77 spans.push(Span::styled(sep.to_string(), style.separator));
78
79 if write_full_owned(&mut spans, &dirs, &mut remaining, &style) {
81 return spans;
82 }
83
84 if dirs.len() == 1 {
86 let s = os_to_str(dirs[0]).into_owned();
87 for level in 1..=3 {
88 let comp = compress_segment(&s, level);
89 if width(&comp) <= remaining {
90 spans.push(Span::styled(comp, style.path));
91 return spans;
92 }
93 }
94 }
95
96 for level in 1..=3 {
98 if write_compressed_elided_owned(&mut spans, &dirs, &mut remaining, level, &style, min_tail) {
99 return spans;
100 }
101 }
102
103 if write_middle_elided_owned(&mut spans, &dirs, &mut remaining, &style, min_tail) {
105 return spans;
106 }
107
108 if let Some(last) = dirs.last()
110 && write_minimal_owned(&mut spans, last, &mut remaining, &style) {
111 return spans;
112 }
113
114 vec![Span::styled(filename_str, style.filename)]
115}
116
117fn write_full_owned(
119 spans: &mut Vec<Span<'static>>,
120 dirs: &[&OsStr],
121 remaining: &mut usize,
122 style: &StyleConfig,
123) -> bool {
124 let mut total_needed: usize = 0;
128 for (i, d) in dirs.iter().enumerate() {
129 if i > 0 {
130 total_needed += 1; }
132 let s = os_to_str(d);
133 total_needed += width(&s);
134 }
135
136 if total_needed > *remaining {
137 return false;
138 }
139
140 write_dirs_owned(spans, dirs, remaining, style)
142}
143
144fn write_dirs_owned(
145 spans: &mut Vec<Span<'static>>,
146 dirs: &[&OsStr],
147 remaining: &mut usize,
148 style: &StyleConfig,
149) -> bool {
150 let start = spans.len();
151
152 for (i, d) in dirs.iter().enumerate() {
153 if i > 0 {
154 if *remaining < 1 {
155 spans.truncate(start);
156 return false;
157 }
158 spans.push(Span::styled("/".to_string(), style.dim));
159 *remaining -= 1;
160 }
161
162 let s = os_to_str(d);
163 let w = width(&s);
164
165 if w > *remaining {
166 spans.truncate(start);
167 return false;
168 }
169
170 spans.push(Span::styled(s.into_owned(), style.path));
171 *remaining -= w;
172 }
173
174 true
175}
176
177fn write_middle_elided_owned(
178 spans: &mut Vec<Span<'static>>,
179 dirs: &[&OsStr],
180 remaining: &mut usize,
181 style: &StyleConfig,
182 min_tail: usize,
183) -> bool {
184 if dirs.len() < 2 {
185 return write_full_owned(spans, dirs, remaining, style);
186 }
187
188 let start = spans.len();
189 let initial_rem = *remaining;
190
191 let first = os_to_str(dirs[0]).into_owned();
193 let fw = width(&first);
194
195 if fw <= *remaining {
196 spans.push(Span::styled(first.clone(), style.path));
197 *remaining -= fw;
198
199 let elide_w = width("…/");
200 for tail_len in (1..dirs.len()).rev() {
201 if tail_len < min_tail {
202 continue;
203 }
204
205 let checkpoint_len = spans.len();
206 let checkpoint_rem = *remaining;
207
208 if *remaining < elide_w {
209 spans.truncate(start);
210 *remaining = initial_rem;
211 break;
212 }
213
214 spans.push(Span::styled("…/".to_string(), style.dim));
215 *remaining -= elide_w;
216
217 let tail = &dirs[dirs.len() - tail_len..];
218
219 let mut ok = true;
220 for (i, d) in tail.iter().enumerate() {
221 if i > 0 {
222 if *remaining < 1 {
223 ok = false;
224 break;
225 }
226 spans.push(Span::styled("/".to_string(), style.dim));
227 *remaining -= 1;
228 }
229
230 let s = os_to_str(d).into_owned();
231 let w = width(&s);
232
233 if w > *remaining {
234 ok = false;
235 break;
236 }
237
238 spans.push(Span::styled(s, style.path));
239 *remaining -= w;
240 }
241
242 if ok {
243 return true;
244 }
245
246 spans.truncate(checkpoint_len);
247 *remaining = checkpoint_rem;
248 }
249
250 spans.truncate(start);
252 *remaining = initial_rem;
253 }
254
255 let elide_w = width("…/");
257 for tail_len in (1..dirs.len()).rev() {
258 if tail_len < min_tail {
259 continue;
260 }
261
262 let checkpoint_len = spans.len();
263 let checkpoint_rem = *remaining;
264
265 if *remaining < elide_w {
266 spans.truncate(start);
267 *remaining = initial_rem;
268 return false;
269 }
270
271 spans.push(Span::styled("…/".to_string(), style.dim));
272 *remaining -= elide_w;
273
274 let tail = &dirs[dirs.len() - tail_len..];
275
276 let mut ok = true;
277 for (i, d) in tail.iter().enumerate() {
278 if i > 0 {
279 if *remaining < 1 {
280 ok = false;
281 break;
282 }
283 spans.push(Span::styled("/".to_string(), style.dim));
284 *remaining -= 1;
285 }
286
287 let s = os_to_str(d).into_owned();
288 let w = width(&s);
289
290 if w > *remaining {
291 ok = false;
292 break;
293 }
294
295 spans.push(Span::styled(s, style.path));
296 *remaining -= w;
297 }
298
299 if ok {
300 return true;
301 }
302
303 spans.truncate(checkpoint_len);
304 *remaining = checkpoint_rem;
305 }
306
307 spans.truncate(start);
308 *remaining = initial_rem;
309 false
310}
311
312fn write_compressed_elided_owned(
313 spans: &mut Vec<Span<'static>>,
314 dirs: &[&OsStr],
315 remaining: &mut usize,
316 level: usize,
317 style: &StyleConfig,
318 min_tail: usize,
319) -> bool {
320 let mut compressed: Vec<String> = Vec::with_capacity(dirs.len());
321
322 for d in dirs {
323 let s = os_to_str(d).into_owned();
324 compressed.push(compress_segment(&s, level));
325 }
326
327 write_middle_elided_strs_owned(spans, &compressed, remaining, style, min_tail)
328}
329
330fn write_middle_elided_strs_owned(
331 spans: &mut Vec<Span<'static>>,
332 dirs: &[String],
333 remaining: &mut usize,
334 style: &StyleConfig,
335 min_tail: usize,
336) -> bool {
337 if dirs.len() < 2 {
338 return false;
339 }
340
341 let start = spans.len();
342 let initial_rem = *remaining;
343
344 let first = &dirs[0];
346 let fw = width(first);
347
348 if fw <= *remaining {
349 spans.push(Span::styled(first.clone(), style.path));
350 *remaining -= fw;
351
352 let elide_w = width("…/");
353 for tail_len in (1..dirs.len()).rev() {
354 if tail_len < min_tail {
355 continue;
356 }
357
358 let checkpoint_len = spans.len();
359 let checkpoint_rem = *remaining;
360
361 if *remaining < elide_w {
362 spans.truncate(start);
363 *remaining = initial_rem;
364 break;
365 }
366
367 spans.push(Span::styled("…/".to_string(), style.dim));
368 *remaining -= elide_w;
369
370 let tail = &dirs[dirs.len() - tail_len..];
371
372 let mut ok = true;
373 for (i, d) in tail.iter().enumerate() {
374 if i > 0 {
375 if *remaining < 1 {
376 ok = false;
377 break;
378 }
379 spans.push(Span::styled("/".to_string(), style.dim));
380 *remaining -= 1;
381 }
382
383 let w = width(d);
384
385 if w > *remaining {
386 ok = false;
387 break;
388 }
389
390 spans.push(Span::styled(d.clone(), style.path));
391 *remaining -= w;
392 }
393
394 if ok {
395 return true;
396 }
397
398 spans.truncate(checkpoint_len);
399 *remaining = checkpoint_rem;
400 }
401
402 spans.truncate(start);
403 *remaining = initial_rem;
404 }
405
406 let elide_w = width("…/");
408 for tail_len in (1..dirs.len()).rev() {
409 if tail_len < min_tail {
410 continue;
411 }
412
413 let checkpoint_len = spans.len();
414 let checkpoint_rem = *remaining;
415
416 if *remaining < elide_w {
417 spans.truncate(start);
418 *remaining = initial_rem;
419 return false;
420 }
421
422 spans.push(Span::styled("…/".to_string(), style.dim));
423 *remaining -= elide_w;
424
425 let tail = &dirs[dirs.len() - tail_len..];
426
427 let mut ok = true;
428 for (i, d) in tail.iter().enumerate() {
429 if i > 0 {
430 if *remaining < 1 {
431 ok = false;
432 break;
433 }
434 spans.push(Span::styled("/".to_string(), style.dim));
435 *remaining -= 1;
436 }
437
438 let w = width(d);
439
440 if w > *remaining {
441 ok = false;
442 break;
443 }
444
445 spans.push(Span::styled(d.clone(), style.path));
446 *remaining -= w;
447 }
448
449 if ok {
450 return true;
451 }
452
453 spans.truncate(checkpoint_len);
454 *remaining = checkpoint_rem;
455 }
456
457 spans.truncate(start);
458 *remaining = initial_rem;
459 false
460}
461
462fn write_minimal_owned(
463 spans: &mut Vec<Span<'static>>,
464 last: &OsStr,
465 remaining: &mut usize,
466 style: &StyleConfig,
467) -> bool {
468 let s = os_to_str(last).into_owned();
469
470 if width("…/") + width(&s) > *remaining {
471 for level in 1..=3 {
473 let comp = compress_segment(&s, level);
474 if width(&comp) <= *remaining {
475 spans.push(Span::styled("…/".to_string(), style.dim));
476 spans.push(Span::styled(comp, style.path));
477 return true;
478 }
479 }
480 return false;
481 }
482
483 spans.push(Span::styled("…/".to_string(), style.dim));
484 spans.push(Span::styled(s, style.path));
485
486 true
487}
488
489fn split_path(path: &Path) -> (&OsStr, Vec<&OsStr>) {
490 let filename = path.file_name().unwrap_or_else(|| "".as_ref());
491
492 let mut dirs = Vec::new();
493 if let Some(parent) = path.parent() {
494 for comp in parent.components() {
495 if let Component::Normal(os) = comp {
496 dirs.push(os);
497 }
498 }
499 }
500 (filename, dirs)
501}
502
503fn os_to_str(os: &OsStr) -> Cow<'_, str> {
504 os.to_string_lossy()
505}
506
507pub fn width(s: &str) -> usize {
508 UnicodeWidthStr::width(s)
509}
510
511fn compress_segment(seg: &str, level: usize) -> String {
512 match level {
513 1 => truncate(seg, 8),
514 2 => truncate(seg, 5),
515 _ => initialism(seg),
516 }
517}
518
519fn truncate(seg: &str, max_len: usize) -> String {
520 if seg.chars().count() <= max_len {
521 return seg.to_string();
522 }
523
524 let mut out = String::with_capacity(max_len);
525
526 for (i, ch) in seg.chars().enumerate() {
527 if i >= max_len - 1 {
528 break;
529 }
530 out.push(ch);
531 }
532
533 out.push('…');
534 out
535}
536
537fn initialism(seg: &str) -> String {
538 let mut out = String::new();
539
540 for part in seg.split(['_', '-', '.']) {
541 if let Some(c) = part.chars().next() {
542 out.push(c);
543 }
544 }
545
546 if out.is_empty() {
547 seg.chars().next().unwrap_or('?').to_string()
548 } else {
549 out
550 }
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556 use std::path::Path;
557 use ratatui::style::Style;
558
559 fn default_cfg() -> StyleConfig {
561 StyleConfig {
562 filename: Style::default(),
563 path: Style::default(),
564 dim: Style::default(),
565 separator: Style::default(),
566 }
567 }
568
569 fn line_to_string(line: &ratatui::text::Line) -> String {
571 line.spans.iter().map(|span| span.content.clone()).collect()
573 }
574
575 #[test]
577 fn test_root_file() {
578 let path = Path::new("main.rs");
579 let line = format_path_line(path, 40);
580 assert_eq!(line_to_string(&line), "main.rs");
581 }
582
583 #[test]
584 fn test_root_file_with_label() {
585 let path = Path::new("Cargo.toml");
586 let cfg = default_cfg();
587 let mut spans = format_path_spans(path, 20, cfg);
588 spans.push(ratatui::text::Span::raw(" (unstaged)"));
589 let s: String = spans.iter().map(|sp| sp.content.clone()).collect();
590 assert_eq!(s, "Cargo.toml (unstaged)");
591 }
592
593 #[test]
595 fn test_single_directory_short() {
596 let path = Path::new("src/main.rs");
597 let line = format_path_line(path, 40);
598 let s = line_to_string(&line);
599 assert!(s.starts_with("main.rs"));
600 assert!(s.contains("src"));
601 }
602
603 #[test]
604 fn test_single_long_directory_elision() {
605 let path = Path::new("very_long_directory_name/main.rs");
606 let line = format_path_line(path, 15);
607 let s = line_to_string(&line);
608 assert!(s.starts_with("main.rs"));
609 assert!(s.contains("…") || s.contains("vldn") || s.contains("very_lo…"));
610 assert!(width(&s) <= 15);
611 }
612
613 #[test]
615 fn test_multiple_directories_wide() {
616 let path = Path::new("src/compiler/parser/ast/main.rs");
617 let line = format_path_line(path, 80);
618 let s = line_to_string(&line);
619 assert!(s.starts_with("main.rs"));
620 assert!(s.contains("src/compiler/parser/ast"));
621 }
622
623 #[test]
624 fn test_multiple_directories_medium() {
625 let path = Path::new("src/compiler/parser/ast/main.rs");
626 let line = format_path_line(path, 30);
627 let s = line_to_string(&line);
628 assert!(s.starts_with("main.rs"));
629 assert!(s.contains("…"));
630 }
631
632 #[test]
633 fn test_multiple_directories_narrow() {
634 let path = Path::new("src/compiler/parser/ast/main.rs");
635 let line = format_path_line(path, 15);
636 let s = line_to_string(&line);
637 assert!(s.starts_with("main.rs"));
638 assert!(s.contains("…"));
639 }
640
641 #[test]
642 fn test_smart_directory_compression_multiple_dirs() {
643 let path = Path::new("src/compiler/very_long_directory_name/parser/ast/main.rs");
645 let line = format_path_line(path, 30);
646 let s = line_to_string(&line);
647 assert!(s.starts_with("main.rs"));
648 assert!(s.contains("…") || s.contains("vldn") || s.contains("very_lo…"));
649 assert!(width(&s) <= 30);
650 }
651
652 #[test]
654 fn test_filename_only_when_too_narrow() {
655 let path = Path::new("src/compiler/parser/ast/main.rs");
656 let line = format_path_line(path, 5);
657 let s = line_to_string(&line);
658 assert_eq!(s, "main.rs");
659 }
660
661 #[test]
663 fn test_unicode_filename() {
664 let path = Path::new("src/解析/メイン.rs");
665 let line = format_path_line(path, 40);
666 let s = line_to_string(&line);
667 assert!(s.contains("メイン.rs"));
668 assert!(s.contains("解析") || s.contains("…"));
669 }
670
671 #[test]
673 fn test_path_with_label_simulation() {
674 let path = Path::new("src/compiler/parser/main.rs");
675 let cfg = default_cfg();
676 let mut spans = format_path_spans(path, 20, cfg);
677 spans.push(ratatui::text::Span::raw(" (staged)"));
678 let s: String = spans.iter().map(|sp| sp.content.clone()).collect();
679 assert!(s.starts_with("main.rs"));
680 assert!(s.contains("…") || s.contains("parser"));
681 assert!(s.contains("(staged)"));
682 }
683}