Coverage Report

Created: 2026-04-05 07:19

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
crates/flotilla-tui/src/ui_helpers.rs
Line
Count
Source
1
use std::path::Path;
2
3
use flotilla_protocol::{ChangeRequestStatus, Checkout, SessionStatus, WorkItemKind};
4
use ratatui::{
5
    layout::{Constraint, Flex, Layout, Rect},
6
    style::Color,
7
    widgets::Block,
8
};
9
10
use crate::theme::Theme;
11
12
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13
pub struct BottomAnchoredOverlayLayout {
14
    pub status_row: Rect,
15
    pub body: Rect,
16
    pub visible_body_rows: u16,
17
}
18
19
/// Truncate a string to `max` characters, appending '…' if truncated.
20
556
pub fn truncate(s: &str, max: usize) -> String {
21
556
    if max == 0 {
22
1
        return String::new();
23
555
    }
24
555
    let char_count: usize = s.chars().count();
25
555
    if char_count <= max {
26
503
        s.to_string()
27
    } else {
28
52
        let truncated: String = s.chars().take(max - 1).collect();
29
52
        format!("{truncated}…")
30
    }
31
556
}
32
33
/// Calculate a centered popup rectangle within `area`.
34
18
pub fn popup_area(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
35
18
    let [area] = Layout::vertical([Constraint::Percentage(percent_y)]).flex(Flex::Center).areas(area);
36
18
    let [area] = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Center).areas(area);
37
18
    area
38
18
}
39
40
/// Calculate a centered popup area and its bordered inner area.
41
///
42
/// Returns `(outer_area, inner_area)` where `inner_area` is the content area
43
/// inside a `Block::bordered()` with the given title.
44
1
pub fn popup_frame(container: Rect, percent_x: u16, percent_y: u16, title: &str, style: ratatui::style::Style) -> (Rect, Rect) {
45
1
    let area = popup_area(container, percent_x, percent_y);
46
1
    let block = Block::bordered().style(style).title(title);
47
1
    let inner = block.inner(area);
48
1
    (area, inner)
49
1
}
50
51
/// Render a popup frame: clear the area and draw a bordered block with title.
52
/// Returns `(outer_area, inner_area)` for the caller to render content into.
53
4
pub fn render_popup_frame(
54
4
    frame: &mut ratatui::Frame,
55
4
    container: Rect,
56
4
    percent_x: u16,
57
4
    percent_y: u16,
58
4
    title: &str,
59
4
    style: ratatui::style::Style,
60
4
) -> (Rect, Rect) {
61
4
    let area = popup_area(container, percent_x, percent_y);
62
4
    frame.render_widget(ratatui::widgets::Clear, area);
63
4
    let block = Block::bordered().style(style).title(title);
64
4
    let inner = block.inner(area);
65
4
    frame.render_widget(block, area);
66
4
    (area, inner)
67
4
}
68
69
12
pub fn bottom_anchored_overlay(frame_area: Rect, reserved_top_rows: u16, requested_body_rows: u16) -> BottomAnchoredOverlayLayout {
70
12
    let visible_body_rows = requested_body_rows.min(frame_area.height.saturating_sub(reserved_top_rows.saturating_add(1)));
71
12
    let status_row_y = frame_area.y + frame_area.height.saturating_sub(visible_body_rows.saturating_add(1));
72
12
    let status_row = Rect::new(frame_area.x, status_row_y, frame_area.width, 1);
73
12
    let body = Rect::new(frame_area.x, status_row_y.saturating_add(1), frame_area.width, visible_body_rows);
74
12
    BottomAnchoredOverlayLayout { status_row, body, visible_body_rows }
75
12
}
76
77
/// Return (icon, color) for a work item based on its kind, workspace status,
78
/// and optional session status.
79
42
pub fn work_item_icon(
80
42
    kind: &WorkItemKind,
81
42
    has_workspace: bool,
82
42
    session_status: Option<&SessionStatus>,
83
42
    theme: &Theme,
84
42
) -> (&'static str, Color) {
85
42
    match kind {
86
        WorkItemKind::Checkout => {
87
28
            if has_workspace {
88
1
                ("●", theme.checkout)
89
            } else {
90
27
                ("○", theme.checkout)
91
            }
92
        }
93
0
        WorkItemKind::AttachableSet => ("◎", theme.checkout),
94
4
        WorkItemKind::Session => match session_status {
95
2
            Some(SessionStatus::Running) => ("▶", theme.session),
96
2
            Some(SessionStatus::Idle) => ("◆", theme.session),
97
0
            Some(SessionStatus::Archived) => ("○", theme.session),
98
0
            Some(SessionStatus::Expired) => ("⊘", theme.session),
99
1
            None => ("○", theme.session),
100
        },
101
5
        WorkItemKind::ChangeRequest => ("⊙", theme.change_request),
102
1
        WorkItemKind::RemoteBranch => ("⊶", theme.remote_branch),
103
3
        WorkItemKind::Issue => ("◇", theme.issue),
104
0
        WorkItemKind::Agent => ("⚙", theme.session),
105
    }
106
42
}
107
108
/// Return the display icon for a session status.
109
8
pub fn session_status_display(status: &SessionStatus) -> &'static str {
110
8
    match status {
111
2
        SessionStatus::Running => "▶",
112
2
        SessionStatus::Idle => "◆",
113
2
        SessionStatus::Archived => "○",
114
2
        SessionStatus::Expired => "⊘",
115
    }
116
8
}
117
118
/// Return the display string for an agent status.
119
0
pub fn agent_status_display(status: &flotilla_protocol::AgentStatus) -> String {
120
0
    match status {
121
0
        flotilla_protocol::AgentStatus::Active => "▶".to_string(),
122
0
        flotilla_protocol::AgentStatus::Idle => "◆".to_string(),
123
0
        flotilla_protocol::AgentStatus::WaitingForPermission => "⏳".to_string(),
124
0
        flotilla_protocol::AgentStatus::WaitingForInput => "⏳".to_string(),
125
0
        flotilla_protocol::AgentStatus::Errored => "⚠".to_string(),
126
    }
127
0
}
128
129
/// Return the display icon for a change request status.
130
8
pub fn change_request_status_icon(status: &ChangeRequestStatus) -> &'static str {
131
8
    match status {
132
1
        ChangeRequestStatus::Merged => "✓",
133
1
        ChangeRequestStatus::Closed => "✗",
134
6
        ChangeRequestStatus::Open | ChangeRequestStatus::Draft => "",
135
    }
136
8
}
137
138
/// Build the git status indicator string (e.g. "MS?↑") from a checkout.
139
20
pub fn git_status_display(checkout: &Checkout) -> String {
140
20
    let mut s = String::new();
141
20
    if checkout.working_tree.as_ref().is_some_and(|w| 
w.modified2
> 0) {
142
2
        s.push('M');
143
18
    }
144
20
    if checkout.working_tree.as_ref().is_some_and(|w| 
w.staged2
> 0) {
145
1
        s.push('S');
146
19
    }
147
20
    if checkout.working_tree.as_ref().is_some_and(|w| 
w.untracked2
> 0) {
148
1
        s.push('?');
149
19
    }
150
20
    if checkout.trunk_ahead_behind.as_ref().is_some_and(|m| 
m.ahead1
> 0) {
151
1
        s.push('↑');
152
19
    }
153
20
    s
154
20
}
155
156
/// Return the checkout indicator: "◆" for main, "✓" for checked out, "" otherwise.
157
30
pub fn checkout_indicator(is_main: bool, has_checkout: bool) -> &'static str {
158
30
    if is_main {
159
11
        "◆"
160
19
    } else if has_checkout {
161
18
        "✓"
162
    } else {
163
1
        ""
164
    }
165
30
}
166
167
/// Shorten a checkout path for display in the table.
168
///
169
/// Main checkout shows home-relative (e.g. `~/dev/flotilla`).
170
/// Other checkouts are indented to show hierarchy: sibling worktrees show the
171
/// suffix (e.g. `.low-hang-12`), nested worktrees show the relative path
172
/// (e.g. `.worktrees/feat-auth`).  Padding matches the parent-directory portion
173
/// of the main display, but shrinks when `col_width` is tight so the actual
174
/// name is preserved.
175
41
pub fn shorten_path(path: &Path, repo_root: &Path, col_width: usize, home_dir: Option<&Path>) -> String {
176
41
    let main_display = shorten_against_home(repo_root, home_dir);
177
178
    // Main checkout — show the full shortened path.
179
41
    if path == repo_root {
180
9
        return main_display;
181
32
    }
182
183
    // Ideal padding = width of the parent-directory portion of main_display.
184
32
    let repo_name_len = repo_root.file_name().map(|n| n.to_string_lossy().len()).unwrap_or(0);
185
32
    let ideal_padding = main_display.len().saturating_sub(repo_name_len);
186
187
    // Cap padding so it never exceeds half the column — leaves room for the name
188
    // even after the caller truncates.  Crucially this is independent of the name
189
    // length, so every worktree at the same depth gets identical indentation.
190
32
    let padding = ideal_padding.min(col_width / 2);
191
192
    // Under repo root (e.g. .worktrees/feat-auth, sub/dir)
193
32
    if let Ok(
rel23
) = path.strip_prefix(repo_root) {
194
23
        let s = rel.to_string_lossy();
195
23
        if !s.is_empty() {
196
23
            let name = s.into_owned();
197
23
            return format!("{:padding$}{name}", "");
198
0
        }
199
9
    }
200
201
    // Sibling or descendant of sibling (shares repo name prefix in first component)
202
9
    if let Some(root_parent) = repo_root.parent() {
203
9
        if let Ok(
rel8
) = path.strip_prefix(root_parent) {
204
8
            let rel_str = rel.to_string_lossy();
205
8
            let root_name = repo_root.file_name().map(|n| n.to_string_lossy()).unwrap_or_default();
206
            // Only handle paths whose first component starts with the repo name
207
            // (e.g. "flotilla.quick-wins/..." but not "unrelated/...")
208
8
            if rel_str.starts_with(root_name.as_ref()) {
209
                // Strip repo name prefix to get the suffix
210
                // e.g. "flotilla.quick-wins" -> ".quick-wins"
211
                // e.g. "flotilla.quick-wins/.claude/worktrees/agent-x" -> ".quick-wins/.claude/worktrees/agent-x"
212
3
                let suffix = rel_str.strip_prefix(root_name.as_ref()).unwrap_or(&rel_str);
213
                // If nested under a sibling (contains '/'), strip the sibling dir
214
                // and show only the sub-path with extra indentation.
215
                // e.g. ".quick-wins/.claude/worktrees/agent-x" -> ".claude/worktrees/agent-x"
216
3
                let (name, extra_indent) = match suffix.find('/') {
217
1
                    Some(pos) => (&suffix[pos + 1..], padding + 2),
218
2
                    None => (suffix, padding),
219
                };
220
3
                let p = extra_indent.min(col_width / 2);
221
3
                return format!("{:p$}{name}", "");
222
5
            }
223
1
        }
224
0
    }
225
226
    // Elsewhere — shorten against home directory.
227
6
    shorten_against_home(path, home_dir)
228
41
}
229
230
47
fn shorten_against_home(path: &Path, home_dir: Option<&Path>) -> String {
231
47
    if let Some(
home22
) = home_dir {
232
22
        if let Ok(
rel5
) = path.strip_prefix(home) {
233
5
            let s = rel.to_string_lossy();
234
5
            if s.is_empty() {
235
0
                return "~".to_string();
236
5
            }
237
5
            return format!("~/{s}");
238
17
        }
239
25
    }
240
42
    path.display().to_string()
241
47
}
242
243
const BRAILLE_SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];
244
245
4
pub fn spinner_char() -> char {
246
    use std::time::{SystemTime, UNIX_EPOCH};
247
4
    let ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis();
248
4
    BRAILLE_SPINNER[(ms / 100) as usize % BRAILLE_SPINNER.len()]
249
4
}
250
251
/// Return the workspace indicator: "" for 0, "●" for 1, count as string for >1.
252
30
pub fn workspace_indicator(count: usize) -> String {
253
30
    match count {
254
27
        0 => String::new(),
255
1
        1 => "●".to_string(),
256
2
        n => format!("{n}"),
257
    }
258
30
}
259
260
#[cfg(test)]
261
mod tests {
262
    use flotilla_protocol::{AheadBehind, WorkingTreeStatus};
263
264
    use super::*;
265
    use crate::theme::Theme;
266
267
    #[test]
268
1
    fn truncate_empty_max() {
269
1
        assert_eq!(truncate("hello", 0), "");
270
1
    }
271
272
    #[test]
273
1
    fn truncate_short_string() {
274
1
        assert_eq!(truncate("hi", 5), "hi");
275
1
    }
276
277
    #[test]
278
1
    fn truncate_exact_length() {
279
1
        assert_eq!(truncate("hello", 5), "hello");
280
1
    }
281
282
    #[test]
283
1
    fn truncate_long_string() {
284
1
        assert_eq!(truncate("hello world", 5), "hell…");
285
1
    }
286
287
    #[test]
288
1
    fn truncate_max_one() {
289
        // max=1 means 0 chars + ellipsis
290
1
        assert_eq!(truncate("hello", 1), "…");
291
1
    }
292
293
    #[test]
294
1
    fn popup_area_centered() {
295
1
        let area = Rect::new(0, 0, 100, 50);
296
1
        let popup = popup_area(area, 50, 50);
297
        // Should be centered and smaller
298
1
        assert!(popup.width <= 50);
299
1
        assert!(popup.height <= 25);
300
1
        assert!(popup.x > 0);
301
1
        assert!(popup.y > 0);
302
1
    }
303
304
    #[test]
305
1
    fn popup_frame_returns_inner_area() {
306
1
        let area = Rect::new(0, 0, 100, 50);
307
1
        let (popup, inner) = popup_frame(area, 50, 50, " Test ", ratatui::style::Style::default());
308
        // Popup should be centered
309
1
        assert!(popup.x > 0);
310
1
        assert!(popup.y > 0);
311
        // Inner should be inset by border (1px each side)
312
1
        assert_eq!(inner.x, popup.x + 1);
313
1
        assert_eq!(inner.y, popup.y + 1);
314
1
        assert_eq!(inner.width, popup.width - 2);
315
1
        assert_eq!(inner.height, popup.height - 2);
316
1
    }
317
318
    #[test]
319
1
    fn bottom_anchored_overlay_clamps_body_rows_to_available_space() {
320
1
        let layout = bottom_anchored_overlay(Rect::new(0, 0, 40, 6), 1, 8);
321
322
1
        assert_eq!(layout.visible_body_rows, 4);
323
1
        assert_eq!(layout.status_row, Rect::new(0, 1, 40, 1));
324
1
        assert_eq!(layout.body, Rect::new(0, 2, 40, 4));
325
1
    }
326
327
    #[test]
328
1
    fn bottom_anchored_overlay_preserves_requested_rows_when_space_allows() {
329
1
        let layout = bottom_anchored_overlay(Rect::new(0, 0, 40, 12), 1, 3);
330
331
1
        assert_eq!(layout.visible_body_rows, 3);
332
1
        assert_eq!(layout.status_row, Rect::new(0, 8, 40, 1));
333
1
        assert_eq!(layout.body, Rect::new(0, 9, 40, 3));
334
1
    }
335
336
    #[test]
337
1
    fn work_item_icon_checkout_with_workspace() {
338
1
        let (icon, color) = work_item_icon(&WorkItemKind::Checkout, true, None, &Theme::classic());
339
1
        assert_eq!(icon, "●");
340
1
        assert_eq!(color, Color::Green);
341
1
    }
342
343
    #[test]
344
1
    fn work_item_icon_checkout_without_workspace() {
345
1
        let (icon, color) = work_item_icon(&WorkItemKind::Checkout, false, None, &Theme::classic());
346
1
        assert_eq!(icon, "○");
347
1
        assert_eq!(color, Color::Green);
348
1
    }
349
350
    #[test]
351
1
    fn work_item_icon_session_running() {
352
1
        let (icon, color) = work_item_icon(&WorkItemKind::Session, false, Some(&SessionStatus::Running), &Theme::classic());
353
1
        assert_eq!(icon, "▶");
354
1
        assert_eq!(color, Color::Magenta);
355
1
    }
356
357
    #[test]
358
1
    fn work_item_icon_session_idle() {
359
1
        let (icon, color) = work_item_icon(&WorkItemKind::Session, false, Some(&SessionStatus::Idle), &Theme::classic());
360
1
        assert_eq!(icon, "◆");
361
1
        assert_eq!(color, Color::Magenta);
362
1
    }
363
364
    #[test]
365
1
    fn work_item_icon_session_none() {
366
1
        let (icon, color) = work_item_icon(&WorkItemKind::Session, false, None, &Theme::classic());
367
1
        assert_eq!(icon, "○");
368
1
        assert_eq!(color, Color::Magenta);
369
1
    }
370
371
    #[test]
372
1
    fn work_item_icon_change_request() {
373
1
        let (icon, color) = work_item_icon(&WorkItemKind::ChangeRequest, false, None, &Theme::classic());
374
1
        assert_eq!(icon, "⊙");
375
1
        assert_eq!(color, Color::Blue);
376
1
    }
377
378
    #[test]
379
1
    fn work_item_icon_remote_branch() {
380
1
        let (icon, color) = work_item_icon(&WorkItemKind::RemoteBranch, false, None, &Theme::classic());
381
1
        assert_eq!(icon, "⊶");
382
1
        assert_eq!(color, Color::DarkGray);
383
1
    }
384
385
    #[test]
386
1
    fn work_item_icon_issue() {
387
1
        let (icon, color) = work_item_icon(&WorkItemKind::Issue, false, None, &Theme::classic());
388
1
        assert_eq!(icon, "◇");
389
1
        assert_eq!(color, Color::Yellow);
390
1
    }
391
392
    #[test]
393
1
    fn session_status_display_all() {
394
1
        assert_eq!(session_status_display(&SessionStatus::Running), "▶");
395
1
        assert_eq!(session_status_display(&SessionStatus::Idle), "◆");
396
1
        assert_eq!(session_status_display(&SessionStatus::Archived), "○");
397
1
        assert_eq!(session_status_display(&SessionStatus::Expired), "⊘");
398
1
    }
399
400
    #[test]
401
1
    fn expired_icon_differs_from_archived() {
402
1
        assert_ne!(
403
1
            session_status_display(&SessionStatus::Expired),
404
1
            session_status_display(&SessionStatus::Archived),
405
            "expired and archived should have distinct icons"
406
        );
407
1
    }
408
409
    #[test]
410
1
    fn change_request_status_icon_all() {
411
1
        assert_eq!(change_request_status_icon(&ChangeRequestStatus::Merged), "✓");
412
1
        assert_eq!(change_request_status_icon(&ChangeRequestStatus::Closed), "✗");
413
1
        assert_eq!(change_request_status_icon(&ChangeRequestStatus::Open), "");
414
1
        assert_eq!(change_request_status_icon(&ChangeRequestStatus::Draft), "");
415
1
    }
416
417
    #[test]
418
1
    fn git_status_display_empty() {
419
1
        let co = Checkout {
420
1
            branch: "main".into(),
421
1
            is_main: true,
422
1
            trunk_ahead_behind: None,
423
1
            remote_ahead_behind: None,
424
1
            working_tree: None,
425
1
            last_commit: None,
426
1
            correlation_keys: vec![],
427
1
            association_keys: vec![],
428
1
            environment_id: None,
429
1
        };
430
1
        assert_eq!(git_status_display(&co), "");
431
1
    }
432
433
    #[test]
434
1
    fn git_status_display_all_flags() {
435
1
        let co = Checkout {
436
1
            branch: "feat".into(),
437
1
            is_main: false,
438
1
            trunk_ahead_behind: Some(AheadBehind { ahead: 3, behind: 0 }),
439
1
            remote_ahead_behind: None,
440
1
            working_tree: Some(WorkingTreeStatus { modified: 2, staged: 1, untracked: 4 }),
441
1
            last_commit: None,
442
1
            correlation_keys: vec![],
443
1
            association_keys: vec![],
444
1
            environment_id: None,
445
1
        };
446
1
        assert_eq!(git_status_display(&co), "MS?↑");
447
1
    }
448
449
    #[test]
450
1
    fn git_status_display_partial() {
451
1
        let co = Checkout {
452
1
            branch: "fix".into(),
453
1
            is_main: false,
454
1
            trunk_ahead_behind: None,
455
1
            remote_ahead_behind: None,
456
1
            working_tree: Some(WorkingTreeStatus { modified: 1, staged: 0, untracked: 0 }),
457
1
            last_commit: None,
458
1
            correlation_keys: vec![],
459
1
            association_keys: vec![],
460
1
            environment_id: None,
461
1
        };
462
1
        assert_eq!(git_status_display(&co), "M");
463
1
    }
464
465
    #[test]
466
1
    fn checkout_indicator_main() {
467
1
        assert_eq!(checkout_indicator(true, true), "◆");
468
1
        assert_eq!(checkout_indicator(true, false), "◆");
469
1
    }
470
471
    #[test]
472
1
    fn checkout_indicator_checked_out() {
473
1
        assert_eq!(checkout_indicator(false, true), "✓");
474
1
    }
475
476
    #[test]
477
1
    fn checkout_indicator_none() {
478
1
        assert_eq!(checkout_indicator(false, false), "");
479
1
    }
480
481
    #[test]
482
1
    fn workspace_indicator_values() {
483
1
        assert_eq!(workspace_indicator(0), "");
484
1
        assert_eq!(workspace_indicator(1), "●");
485
1
        assert_eq!(workspace_indicator(2), "2");
486
1
        assert_eq!(workspace_indicator(10), "10");
487
1
    }
488
489
    #[test]
490
1
    fn spinner_char_returns_valid_braille() {
491
1
        let ch = spinner_char();
492
1
        assert!(BRAILLE_SPINNER.contains(&ch));
493
1
    }
494
495
    #[test]
496
1
    fn shorten_path_main_checkout() {
497
1
        let root = Path::new("/dev/project");
498
1
        assert_eq!(shorten_path(root, root, 40, None), "/dev/project");
499
1
    }
500
501
    #[test]
502
1
    fn shorten_path_main_checkout_under_home() {
503
1
        let home = dirs::home_dir().expect("home dir");
504
1
        let root = home.join("dev/flotilla");
505
1
        assert_eq!(shorten_path(&root, &root, 40, Some(&home)), "~/dev/flotilla");
506
1
    }
507
508
    #[test]
509
1
    fn shorten_path_worktree_wide_column() {
510
        // Wide column — full padding (5 = len("/dev/")).
511
1
        let root = Path::new("/dev/project");
512
1
        let wt = Path::new("/dev/project/.worktrees/feat-auth");
513
1
        assert_eq!(shorten_path(wt, root, 40, None), "     .worktrees/feat-auth");
514
1
    }
515
516
    #[test]
517
1
    fn shorten_path_worktree_narrow_column() {
518
        // Narrow column — padding is consistent (capped at col/2), caller truncates.
519
1
        let root = Path::new("/dev/project");
520
1
        let wt = Path::new("/dev/project/.worktrees/feat-auth");
521
        // ideal_padding = 5, col/2 = 11 → padding = 5 (same as wide)
522
1
        assert_eq!(shorten_path(wt, root, 22, None), "     .worktrees/feat-auth");
523
1
    }
524
525
    #[test]
526
1
    fn shorten_path_worktree_very_narrow() {
527
        // Very narrow — padding capped at col/2 = 5, still consistent indent.
528
1
        let root = Path::new("/dev/project");
529
1
        let wt = Path::new("/dev/project/.worktrees/feat-auth");
530
1
        assert_eq!(shorten_path(wt, root, 10, None), "     .worktrees/feat-auth");
531
1
    }
532
533
    #[test]
534
1
    fn shorten_path_relative() {
535
1
        let root = Path::new("/dev/project");
536
1
        let sub = Path::new("/dev/project/sub/dir");
537
1
        assert_eq!(shorten_path(sub, root, 40, None), "     sub/dir");
538
1
    }
539
540
    #[test]
541
1
    fn shorten_path_nested_worktree() {
542
1
        let root = Path::new("/dev/project");
543
1
        let wt = Path::new("/dev/project/.worktrees/group/feat-auth");
544
1
        assert_eq!(shorten_path(wt, root, 40, None), "     .worktrees/group/feat-auth");
545
1
    }
546
547
    #[test]
548
1
    fn shorten_path_sibling_worktree() {
549
        // padding = len("/dev/") = 5
550
1
        let root = Path::new("/dev/flotilla");
551
1
        let wt = Path::new("/dev/flotilla.feat-xyz");
552
1
        assert_eq!(shorten_path(wt, root, 40, None), "     .feat-xyz");
553
1
    }
554
555
    #[test]
556
1
    fn shorten_path_sibling_different_name() {
557
        // Sibling with a different name prefix is not a related worktree
558
1
        let root = Path::new("/dev/flotilla");
559
1
        let wt = Path::new("/dev/other-project");
560
1
        assert_eq!(shorten_path(wt, root, 40, None), "/dev/other-project");
561
1
    }
562
563
    #[test]
564
1
    fn shorten_path_sibling_under_home() {
565
        // padding = len("~/dev/") = 6
566
1
        let home = dirs::home_dir().expect("home dir");
567
1
        let root = home.join("dev/flotilla");
568
1
        let wt = home.join("dev/flotilla.low-hang-12");
569
1
        assert_eq!(shorten_path(&wt, &root, 40, Some(&home)), "      .low-hang-12");
570
1
    }
571
572
    #[test]
573
1
    fn shorten_path_nested_under_sibling() {
574
        // Worktree created by Claude agent under a sibling worktree.
575
        // Strips the sibling dir (.quick-wins) and adds extra indent.
576
        // padding = len("/dev/") = 5, +2 extra = 7
577
1
        let root = Path::new("/dev/flotilla");
578
1
        let wt = Path::new("/dev/flotilla.quick-wins/.claude/worktrees/agent-abc");
579
1
        assert_eq!(shorten_path(wt, root, 60, None), "       .claude/worktrees/agent-abc");
580
1
    }
581
582
    #[test]
583
1
    fn shorten_path_unrelated_under_same_parent() {
584
        // Unrelated directory under the same parent should NOT be treated as sibling
585
1
        let root = Path::new("/dev/flotilla");
586
1
        let other = Path::new("/dev/unrelated/sub");
587
1
        assert_eq!(shorten_path(other, root, 40, None), "/dev/unrelated/sub");
588
1
    }
589
590
    #[test]
591
1
    fn shorten_path_outside_root() {
592
1
        let root = Path::new("/tmp/project");
593
1
        let other = Path::new("/elsewhere/wt");
594
1
        assert_eq!(shorten_path(other, root, 40, None), "/elsewhere/wt");
595
1
    }
596
597
    #[test]
598
1
    fn shorten_path_remote_host_home() {
599
1
        let remote_home = Path::new("/home/remoteuser");
600
1
        let root = Path::new("/home/remoteuser/dev/project");
601
1
        assert_eq!(shorten_path(root, root, 40, Some(remote_home)), "~/dev/project");
602
1
    }
603
604
    #[test]
605
1
    fn shorten_path_no_home_dir() {
606
1
        let root = Path::new("/srv/repos/project");
607
1
        assert_eq!(shorten_path(root, root, 40, None), "/srv/repos/project");
608
1
    }
609
}