Skip to main content

tam_worktree/
pretty.rs

1use anyhow::{bail, Result};
2use colored::Colorize;
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7/// A discovered project with its computed pretty name.
8#[derive(Debug, Clone)]
9pub struct PrettyEntry {
10    /// Absolute path to the project or worktree root.
11    pub path: PathBuf,
12    /// Display name with disambiguation suffix (e.g. `"myapp [projects]"`).
13    pub display_name: String,
14    /// Short name before disambiguation (e.g. "feature" for a worktree, "myapp" for a repo)
15    pub base_name: String,
16    /// If this is a worktree, the name of the main repo it belongs to
17    pub worktree_of: Option<String>,
18    sort_key: SortKey,
19}
20
21/// Sort key for pretty entries: (group_name, is_worktree, short_name).
22/// Worktrees group under their main repo name and appear after it.
23type SortKey = (String, u8, String);
24
25/// Detect whether a path is a git worktree (`.git` is a file, not a directory).
26pub fn is_worktree(path: &Path) -> bool {
27    let git_entry = path.join(".git");
28    git_entry.is_file()
29}
30
31/// For a worktree, parse the `.git` file to find the main repository name.
32/// The `.git` file contains a line like `gitdir: /path/to/main/.git/worktrees/<name>`.
33/// We extract the main repo's basename from this.
34pub fn worktree_main_repo_name(path: &Path) -> Result<String> {
35    let git_file = path.join(".git");
36    let content = fs::read_to_string(&git_file)?;
37    let gitdir = content.strip_prefix("gitdir: ").unwrap_or(&content).trim();
38    // gitdir is like: /path/to/main-repo/.git/worktrees/<name>
39    // We want the main-repo basename.
40    let gitdir_path = PathBuf::from(gitdir);
41    // Walk up from worktrees/<name> -> .git -> main-repo
42    let main_git_dir = gitdir_path
43        .parent() // worktrees
44        .and_then(|p| p.parent()) // .git
45        .and_then(|p| p.parent()) // main-repo
46        .and_then(|p| p.file_name())
47        .map(|n| n.to_string_lossy().to_string());
48    match main_git_dir {
49        Some(name) => Ok(name),
50        None => bail!(
51            "could not determine main repo from worktree at {}",
52            path.display()
53        ),
54    }
55}
56
57/// Build pretty names for a list of discovered project paths.
58///
59/// Rules:
60/// 1. Base display name is the directory basename.
61/// 2. If worktree: strip `<project>--` prefix, annotate with `@<project>`.
62/// 3. Disambiguate collisions with shortest unique parent path suffix.
63pub fn build_pretty_names(paths: &[PathBuf]) -> Vec<PrettyEntry> {
64    // Step 1: compute base names
65    let mut entries: Vec<(PathBuf, String, Option<String>, SortKey)> = Vec::new();
66
67    for path in paths {
68        let basename = path
69            .file_name()
70            .map(|n| n.to_string_lossy().to_string())
71            .unwrap_or_default();
72
73        if is_worktree(path) {
74            if let Ok(main_name) = worktree_main_repo_name(path) {
75                let prefix = format!("{}--", main_name);
76                let short_name = if basename.starts_with(&prefix) {
77                    basename[prefix.len()..].to_string()
78                } else {
79                    basename.clone()
80                };
81                let sort_key = (main_name.to_lowercase(), 1, short_name.to_lowercase());
82                entries.push((path.clone(), short_name, Some(main_name), sort_key));
83                continue;
84            }
85        }
86
87        let sort_key = (basename.to_lowercase(), 0, String::new());
88        entries.push((path.clone(), basename, None, sort_key));
89    }
90
91    // Step 2: find collisions on base name and disambiguate
92    let mut name_counts: HashMap<String, Vec<usize>> = HashMap::new();
93    for (i, (_, name, _, _)) in entries.iter().enumerate() {
94        name_counts.entry(name.clone()).or_default().push(i);
95    }
96
97    let mut result: Vec<PrettyEntry> = entries
98        .iter()
99        .map(|(path, name, worktree_of, sort_key)| {
100            let display = match worktree_of {
101                Some(main_name) => format!("{} @{}", name, main_name),
102                None => name.clone(),
103            };
104            PrettyEntry {
105                path: path.clone(),
106                display_name: display,
107                base_name: name.clone(),
108                worktree_of: worktree_of.clone(),
109                sort_key: sort_key.clone(),
110            }
111        })
112        .collect();
113
114    // Disambiguate collisions
115    for indices in name_counts.values() {
116        if indices.len() <= 1 {
117            continue;
118        }
119
120        // Find the shortest unique parent path suffix for each colliding entry
121        let paths_for_collision: Vec<&Path> =
122            indices.iter().map(|&i| entries[i].0.as_path()).collect();
123        let suffixes = shortest_unique_suffixes(&paths_for_collision);
124
125        for (j, &idx) in indices.iter().enumerate() {
126            let (_, ref base_name, ref worktree_of, _) = entries[idx];
127            let display = match worktree_of {
128                Some(main_name) => {
129                    format!("{} ({}) @{}", base_name, suffixes[j], main_name)
130                }
131                None => format!("{} ({})", base_name, suffixes[j]),
132            };
133            result[idx].display_name = display;
134        }
135    }
136
137    // Sort: alphabetical, with worktrees grouped after their main repo
138    result.sort_by(|a, b| a.sort_key.cmp(&b.sort_key));
139
140    result
141}
142
143/// Find the shortest unique parent path suffix to disambiguate a set of paths.
144///
145/// For example, given:
146///   /home/user/Workspace/mnemo
147///   /home/user/Documents/projects/mnemo
148///
149/// Returns: ["Workspace", "Documents/projects"]
150/// (the shortest suffix of the parent path that makes each unique)
151fn shortest_unique_suffixes(paths: &[&Path]) -> Vec<String> {
152    let parents: Vec<Vec<String>> = paths
153        .iter()
154        .map(|p| {
155            let parent = p.parent().unwrap_or(Path::new(""));
156            parent
157                .components()
158                .map(|c| c.as_os_str().to_string_lossy().to_string())
159                .collect::<Vec<_>>()
160        })
161        .collect();
162
163    let n = paths.len();
164    let mut suffixes = vec![String::new(); n];
165
166    // Start with 1 component from the end, increase until all are unique
167    let max_components = parents.iter().map(|p| p.len()).max().unwrap_or(0);
168
169    for depth in 1..=max_components {
170        let tails: Vec<String> = parents
171            .iter()
172            .map(|components| {
173                let start = components.len().saturating_sub(depth);
174                components[start..].join("/")
175            })
176            .collect();
177
178        // Check which are now unique
179        let mut seen: HashMap<&str, Vec<usize>> = HashMap::new();
180        for (i, tail) in tails.iter().enumerate() {
181            seen.entry(tail.as_str()).or_default().push(i);
182        }
183
184        for (i, tail) in tails.iter().enumerate() {
185            if suffixes[i].is_empty() && seen[tail.as_str()].len() == 1 {
186                suffixes[i] = tail.clone();
187            }
188        }
189
190        if suffixes.iter().all(|s| !s.is_empty()) {
191            break;
192        }
193    }
194
195    suffixes
196}
197
198/// Get the display name for tree output.
199///
200/// For worktrees, strips the ` @<parent>` annotation from `display_name`
201/// since the tree structure already shows the parent relationship.
202fn tree_name(entry: &PrettyEntry) -> &str {
203    if let Some(ref parent) = entry.worktree_of {
204        let suffix = format!(" @{}", parent);
205        entry
206            .display_name
207            .strip_suffix(&suffix)
208            .unwrap_or(&entry.display_name)
209    } else {
210        &entry.display_name
211    }
212}
213
214/// Render pretty entries as a tree with colors.
215///
216/// Regular repos are shown as bold top-level entries.
217/// Worktrees are grouped under their parent repo with tree-drawing characters.
218pub fn build_tree_output(entries: &[PrettyEntry]) -> Vec<String> {
219    let mut lines = Vec::new();
220    let mut i = 0;
221    while i < entries.len() {
222        let entry = &entries[i];
223        if entry.worktree_of.is_some() {
224            // Orphan worktree (no parent in the list) — show standalone
225            let prefix = "└─ ".dimmed();
226            lines.push(format!("{}{}", prefix, tree_name(entry).green()));
227            i += 1;
228            continue;
229        }
230
231        // Regular repo — collect its worktrees
232        lines.push(format!("{}", tree_name(entry).bold()));
233        let repo_name = &entry.base_name;
234        i += 1;
235
236        // Gather consecutive worktrees belonging to this repo
237        let wt_start = i;
238        while i < entries.len() && entries[i].worktree_of.as_deref() == Some(repo_name) {
239            i += 1;
240        }
241        let wt_end = i;
242
243        let wt_count = wt_end - wt_start;
244        for (j, entry) in entries[wt_start..wt_end].iter().enumerate() {
245            let is_last = j == wt_count - 1;
246            let connector = if is_last { "└─ " } else { "├─ " };
247            lines.push(format!(
248                "{}{}",
249                connector.dimmed(),
250                tree_name(entry).green()
251            ));
252        }
253    }
254    lines
255}
256
257/// Map an absolute path to its pretty display name.
258///
259/// Builds the same pretty names and finds the matching entry.
260/// Errors if the path is not in the list.
261pub fn prettify(path: &Path, paths: &[PathBuf]) -> Result<String> {
262    let canonical = fs::canonicalize(path)?;
263    let entries = build_pretty_names(paths);
264    for entry in &entries {
265        if let Ok(entry_canonical) = fs::canonicalize(&entry.path) {
266            if entry_canonical == canonical {
267                return Ok(entry.display_name.clone());
268            }
269        }
270    }
271    bail!("no project matches path '{}'", path.display())
272}
273
274/// Resolve a pretty name to an absolute path.
275///
276/// Builds the same pretty names and finds the matching entry.
277/// Errors if no match or ambiguous.
278pub fn resolve(pretty_name: &str, paths: &[PathBuf]) -> Result<PathBuf> {
279    let entries = build_pretty_names(paths);
280    let matches: Vec<&PrettyEntry> = entries
281        .iter()
282        .filter(|e| e.display_name == pretty_name)
283        .collect();
284
285    match matches.len() {
286        0 => bail!("no project matches '{}'", pretty_name),
287        1 => Ok(matches[0].path.clone()),
288        _ => bail!(
289            "ambiguous name '{}' matches {} projects",
290            pretty_name,
291            matches.len()
292        ),
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use std::fs;
300    use tempfile::TempDir;
301
302    fn make_git_repo(path: &Path) {
303        fs::create_dir_all(path).unwrap();
304        fs::create_dir(path.join(".git")).unwrap();
305    }
306
307    fn make_git_worktree(path: &Path, main_repo: &Path) {
308        fs::create_dir_all(path).unwrap();
309        let worktree_name = path.file_name().unwrap().to_string_lossy();
310        let gitdir = main_repo
311            .join(".git")
312            .join("worktrees")
313            .join(worktree_name.as_ref());
314        fs::create_dir_all(&gitdir).unwrap();
315        fs::write(path.join(".git"), format!("gitdir: {}", gitdir.display())).unwrap();
316    }
317
318    #[test]
319    fn test_is_worktree_regular_repo() {
320        let tmp = TempDir::new().unwrap();
321        let repo = tmp.path().join("repo");
322        make_git_repo(&repo);
323        assert!(!is_worktree(&repo));
324    }
325
326    #[test]
327    fn test_is_worktree_actual_worktree() {
328        let tmp = TempDir::new().unwrap();
329        let main_repo = tmp.path().join("main");
330        make_git_repo(&main_repo);
331        let wt = tmp.path().join("main--feature");
332        make_git_worktree(&wt, &main_repo);
333        assert!(is_worktree(&wt));
334    }
335
336    #[test]
337    fn test_worktree_main_repo_name() {
338        let tmp = TempDir::new().unwrap();
339        let main_repo = tmp.path().join("cargostack-backend");
340        make_git_repo(&main_repo);
341        let wt = tmp.path().join("cargostack-backend--fix-branch");
342        make_git_worktree(&wt, &main_repo);
343
344        let name = worktree_main_repo_name(&wt).unwrap();
345        assert_eq!(name, "cargostack-backend");
346    }
347
348    #[test]
349    fn test_pretty_simple_repos() {
350        let tmp = TempDir::new().unwrap();
351        let repo_a = tmp.path().join("alpha");
352        let repo_b = tmp.path().join("beta");
353        make_git_repo(&repo_a);
354        make_git_repo(&repo_b);
355
356        let paths = vec![repo_a, repo_b];
357        let entries = build_pretty_names(&paths);
358        assert_eq!(entries[0].display_name, "alpha");
359        assert_eq!(entries[1].display_name, "beta");
360    }
361
362    #[test]
363    fn test_pretty_worktree_annotation() {
364        let tmp = TempDir::new().unwrap();
365        let main_repo = tmp.path().join("cargostack-backend");
366        make_git_repo(&main_repo);
367        let wt = tmp.path().join("cargostack-backend--fix-branch");
368        make_git_worktree(&wt, &main_repo);
369
370        let paths = vec![main_repo, wt];
371        let entries = build_pretty_names(&paths);
372        assert_eq!(entries[0].display_name, "cargostack-backend");
373        assert_eq!(entries[1].display_name, "fix-branch @cargostack-backend");
374    }
375
376    #[test]
377    fn test_pretty_collision_disambiguation() {
378        let tmp = TempDir::new().unwrap();
379        let mnemo_ws = tmp.path().join("Workspace").join("mnemo");
380        let mnemo_docs = tmp.path().join("Documents").join("projects").join("mnemo");
381        make_git_repo(&mnemo_ws);
382        make_git_repo(&mnemo_docs);
383
384        let paths = vec![mnemo_ws, mnemo_docs];
385        let entries = build_pretty_names(&paths);
386
387        // Both are "mnemo" so they need disambiguation
388        assert!(
389            entries[0].display_name.contains("Workspace"),
390            "expected Workspace disambiguation, got: {}",
391            entries[0].display_name
392        );
393        assert!(
394            entries[1].display_name.contains("projects"),
395            "expected projects disambiguation, got: {}",
396            entries[1].display_name
397        );
398    }
399
400    #[test]
401    fn test_pretty_collision_shortest_suffix() {
402        let tmp = TempDir::new().unwrap();
403        // These share "projects" parent, so we need to go one level up
404        let mnemo_a = tmp.path().join("a").join("projects").join("mnemo");
405        let mnemo_b = tmp.path().join("b").join("projects").join("mnemo");
406        make_git_repo(&mnemo_a);
407        make_git_repo(&mnemo_b);
408
409        let paths = vec![mnemo_a, mnemo_b];
410        let entries = build_pretty_names(&paths);
411
412        // "projects" alone isn't unique, so should include "a/projects" and "b/projects"
413        assert!(
414            entries[0].display_name.contains("a/projects") || entries[0].display_name.contains("a"),
415            "expected 'a' disambiguation, got: {}",
416            entries[0].display_name
417        );
418        assert!(
419            entries[1].display_name.contains("b/projects") || entries[1].display_name.contains("b"),
420            "expected 'b' disambiguation, got: {}",
421            entries[1].display_name
422        );
423    }
424
425    #[test]
426    fn test_pretty_no_collision_no_suffix() {
427        let tmp = TempDir::new().unwrap();
428        let alpha = tmp.path().join("alpha");
429        let beta = tmp.path().join("beta");
430        make_git_repo(&alpha);
431        make_git_repo(&beta);
432
433        let paths = vec![alpha, beta];
434        let entries = build_pretty_names(&paths);
435        assert!(!entries[0].display_name.contains('('));
436        assert!(!entries[1].display_name.contains('('));
437    }
438
439    #[test]
440    fn test_pretty_worktree_with_collision() {
441        // A worktree whose short name collides with another project
442        let tmp = TempDir::new().unwrap();
443
444        let feature_repo = tmp.path().join("Workspace").join("feature");
445        make_git_repo(&feature_repo);
446
447        let main_repo = tmp.path().join("worktrees").join("myapp");
448        make_git_repo(&main_repo);
449
450        let wt = tmp.path().join("worktrees").join("myapp--feature");
451        make_git_worktree(&wt, &main_repo);
452
453        let paths = vec![feature_repo, wt];
454        let entries = build_pretty_names(&paths);
455
456        // Both have base name "feature", so both should be disambiguated
457        // The worktree should also have its annotation
458        let wt_entry = &entries[1];
459        assert!(
460            wt_entry.display_name.contains("@myapp"),
461            "expected worktree annotation, got: {}",
462            wt_entry.display_name
463        );
464        assert!(
465            wt_entry.display_name.contains('('),
466            "expected disambiguation, got: {}",
467            wt_entry.display_name
468        );
469    }
470
471    #[test]
472    fn test_prettify_simple() {
473        let tmp = TempDir::new().unwrap();
474        let repo_a = tmp.path().join("alpha");
475        let repo_b = tmp.path().join("beta");
476        make_git_repo(&repo_a);
477        make_git_repo(&repo_b);
478
479        let paths = vec![repo_a.clone(), repo_b];
480        let result = prettify(&repo_a, &paths).unwrap();
481        assert_eq!(result, "alpha");
482    }
483
484    #[test]
485    fn test_prettify_worktree() {
486        let tmp = TempDir::new().unwrap();
487        let main_repo = tmp.path().join("myapp");
488        make_git_repo(&main_repo);
489        let wt = tmp.path().join("myapp--feature");
490        make_git_worktree(&wt, &main_repo);
491
492        let paths = vec![main_repo, wt.clone()];
493        let result = prettify(&wt, &paths).unwrap();
494        assert_eq!(result, "feature @myapp");
495    }
496
497    #[test]
498    fn test_prettify_no_match() {
499        let tmp = TempDir::new().unwrap();
500        let repo = tmp.path().join("alpha");
501        let other = tmp.path().join("nonexistent");
502        make_git_repo(&repo);
503        fs::create_dir_all(&other).unwrap();
504
505        let paths = vec![repo];
506        let result = prettify(&other, &paths);
507        assert!(result.is_err());
508    }
509
510    #[test]
511    fn test_prettify_roundtrip() {
512        let tmp = TempDir::new().unwrap();
513        let repo_a = tmp.path().join("alpha");
514        let repo_b = tmp.path().join("beta");
515        make_git_repo(&repo_a);
516        make_git_repo(&repo_b);
517
518        let paths = vec![repo_a.clone(), repo_b];
519        let name = prettify(&repo_a, &paths).unwrap();
520        let resolved = resolve(&name, &paths).unwrap();
521        assert_eq!(resolved, repo_a);
522    }
523
524    #[test]
525    fn test_resolve_exact_match() {
526        let tmp = TempDir::new().unwrap();
527        let repo_a = tmp.path().join("alpha");
528        let repo_b = tmp.path().join("beta");
529        make_git_repo(&repo_a);
530        make_git_repo(&repo_b);
531
532        let paths = vec![repo_a.clone(), repo_b];
533        let result = resolve("alpha", &paths).unwrap();
534        assert_eq!(result, repo_a);
535    }
536
537    #[test]
538    fn test_resolve_worktree() {
539        let tmp = TempDir::new().unwrap();
540        let main_repo = tmp.path().join("myapp");
541        make_git_repo(&main_repo);
542        let wt = tmp.path().join("myapp--feature");
543        make_git_worktree(&wt, &main_repo);
544
545        let paths = vec![main_repo, wt.clone()];
546        let result = resolve("feature @myapp", &paths).unwrap();
547        assert_eq!(result, wt);
548    }
549
550    #[test]
551    fn test_resolve_no_match() {
552        let tmp = TempDir::new().unwrap();
553        let repo = tmp.path().join("alpha");
554        make_git_repo(&repo);
555
556        let paths = vec![repo];
557        let result = resolve("nonexistent", &paths);
558        assert!(result.is_err());
559    }
560
561    #[test]
562    fn test_resolve_disambiguated_name() {
563        let tmp = TempDir::new().unwrap();
564        let mnemo_ws = tmp.path().join("Workspace").join("mnemo");
565        let mnemo_docs = tmp.path().join("Documents").join("mnemo");
566        make_git_repo(&mnemo_ws);
567        make_git_repo(&mnemo_docs);
568
569        let paths = vec![mnemo_ws.clone(), mnemo_docs.clone()];
570        let entries = build_pretty_names(&paths);
571
572        // Resolve using the disambiguated name
573        let result = resolve(&entries[0].display_name, &paths).unwrap();
574        assert_eq!(result, mnemo_ws);
575
576        let result = resolve(&entries[1].display_name, &paths).unwrap();
577        assert_eq!(result, mnemo_docs);
578    }
579
580    #[test]
581    fn test_shortest_unique_suffixes_simple() {
582        let a = PathBuf::from("/home/user/Workspace/mnemo");
583        let b = PathBuf::from("/home/user/Documents/projects/mnemo");
584        let paths: Vec<&Path> = vec![a.as_path(), b.as_path()];
585        let suffixes = shortest_unique_suffixes(&paths);
586        assert_eq!(suffixes[0], "Workspace");
587        assert_eq!(suffixes[1], "projects");
588    }
589
590    #[test]
591    fn test_shortest_unique_suffixes_deeper() {
592        let a = PathBuf::from("/x/a/shared/mnemo");
593        let b = PathBuf::from("/x/b/shared/mnemo");
594        let paths: Vec<&Path> = vec![a.as_path(), b.as_path()];
595        let suffixes = shortest_unique_suffixes(&paths);
596        // "shared" is the same for both, so need "a/shared" and "b/shared"
597        assert_eq!(suffixes[0], "a/shared");
598        assert_eq!(suffixes[1], "b/shared");
599    }
600
601    #[test]
602    fn test_sorted_worktrees_after_main_repo() {
603        let tmp = TempDir::new().unwrap();
604
605        let zebra = tmp.path().join("zebra");
606        make_git_repo(&zebra);
607
608        let alpha = tmp.path().join("alpha");
609        make_git_repo(&alpha);
610
611        let myapp = tmp.path().join("myapp");
612        make_git_repo(&myapp);
613
614        let wt_feature = tmp.path().join("myapp--feature");
615        make_git_worktree(&wt_feature, &myapp);
616
617        let wt_bugfix = tmp.path().join("myapp--bugfix");
618        make_git_worktree(&wt_bugfix, &myapp);
619
620        // Pass in unsorted order
621        let paths = vec![zebra, wt_feature, alpha, wt_bugfix, myapp];
622        let entries = build_pretty_names(&paths);
623        let names: Vec<&str> = entries.iter().map(|e| e.display_name.as_str()).collect();
624
625        assert_eq!(
626            names,
627            vec!["alpha", "myapp", "bugfix @myapp", "feature @myapp", "zebra",]
628        );
629    }
630
631    #[test]
632    fn test_sorted_case_insensitive() {
633        let tmp = TempDir::new().unwrap();
634        let upper = tmp.path().join("Zebra");
635        let lower = tmp.path().join("alpha");
636        make_git_repo(&upper);
637        make_git_repo(&lower);
638
639        let paths = vec![upper, lower];
640        let entries = build_pretty_names(&paths);
641        let names: Vec<&str> = entries.iter().map(|e| e.display_name.as_str()).collect();
642        assert_eq!(names, vec!["alpha", "Zebra"]);
643    }
644
645    #[test]
646    fn test_three_way_collision() {
647        let tmp = TempDir::new().unwrap();
648        let a = tmp.path().join("x").join("mnemo");
649        let b = tmp.path().join("y").join("mnemo");
650        let c = tmp.path().join("z").join("mnemo");
651        make_git_repo(&a);
652        make_git_repo(&b);
653        make_git_repo(&c);
654
655        let paths = vec![a, b, c];
656        let entries = build_pretty_names(&paths);
657
658        // All three should be disambiguated and unique
659        let names: Vec<&str> = entries.iter().map(|e| e.display_name.as_str()).collect();
660        assert_eq!(names.len(), 3);
661        assert_ne!(names[0], names[1]);
662        assert_ne!(names[1], names[2]);
663        assert_ne!(names[0], names[2]);
664    }
665
666    #[test]
667    fn test_tree_output_no_worktrees() {
668        let tmp = TempDir::new().unwrap();
669        let alpha = tmp.path().join("alpha");
670        let beta = tmp.path().join("beta");
671        make_git_repo(&alpha);
672        make_git_repo(&beta);
673
674        let paths = vec![alpha, beta];
675        let entries = build_pretty_names(&paths);
676        let lines = build_tree_output(&entries);
677        // Strip ANSI codes for comparison
678        let plain: Vec<String> = lines.iter().map(|l| strip_ansi(l)).collect();
679        assert_eq!(plain, vec!["alpha", "beta"]);
680    }
681
682    #[test]
683    fn test_tree_output_with_worktrees() {
684        let tmp = TempDir::new().unwrap();
685        let myapp = tmp.path().join("myapp");
686        make_git_repo(&myapp);
687        let wt_bug = tmp.path().join("myapp--bugfix");
688        make_git_worktree(&wt_bug, &myapp);
689        let wt_feat = tmp.path().join("myapp--feature");
690        make_git_worktree(&wt_feat, &myapp);
691        let zebra = tmp.path().join("zebra");
692        make_git_repo(&zebra);
693
694        let paths = vec![myapp, wt_bug, wt_feat, zebra];
695        let entries = build_pretty_names(&paths);
696        let lines = build_tree_output(&entries);
697        let plain: Vec<String> = lines.iter().map(|l| strip_ansi(l)).collect();
698        assert_eq!(plain, vec!["myapp", "├─ bugfix", "└─ feature", "zebra"]);
699    }
700
701    #[test]
702    fn test_tree_output_single_worktree() {
703        let tmp = TempDir::new().unwrap();
704        let myapp = tmp.path().join("myapp");
705        make_git_repo(&myapp);
706        let wt = tmp.path().join("myapp--feature");
707        make_git_worktree(&wt, &myapp);
708
709        let paths = vec![myapp, wt];
710        let entries = build_pretty_names(&paths);
711        let lines = build_tree_output(&entries);
712        let plain: Vec<String> = lines.iter().map(|l| strip_ansi(l)).collect();
713        assert_eq!(plain, vec!["myapp", "└─ feature"]);
714    }
715
716    #[test]
717    fn test_tree_output_disambiguated_repos() {
718        let tmp = TempDir::new().unwrap();
719        let notes_personal = tmp.path().join("personal").join("notes");
720        let notes_work = tmp.path().join("work").join("notes");
721        make_git_repo(&notes_personal);
722        make_git_repo(&notes_work);
723
724        let paths = vec![notes_personal, notes_work];
725        let entries = build_pretty_names(&paths);
726        let lines = build_tree_output(&entries);
727        let plain: Vec<String> = lines.iter().map(|l| strip_ansi(l)).collect();
728
729        assert_eq!(plain.len(), 2);
730        assert!(
731            plain[0].contains("personal"),
732            "expected disambiguation, got: {}",
733            plain[0]
734        );
735        assert!(
736            plain[1].contains("work"),
737            "expected disambiguation, got: {}",
738            plain[1]
739        );
740    }
741
742    /// Strip ANSI escape codes from a string for test assertions.
743    fn strip_ansi(s: &str) -> String {
744        let mut result = String::new();
745        let mut chars = s.chars();
746        while let Some(c) = chars.next() {
747            if c == '\x1b' {
748                // Skip until 'm'
749                for inner in chars.by_ref() {
750                    if inner == 'm' {
751                        break;
752                    }
753                }
754            } else {
755                result.push(c);
756            }
757        }
758        result
759    }
760}