From 7c695b98ad4d6f1595819c3adce8846d625a4f5d Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Thu, 17 Sep 2026 10:37:42 +0200 Subject: [PATCH] fix: show scan-root-relative paths for recursively found files Recursive directory scanning printed every file by basename only, so same-named files in different subdirectories were indistinguishable in progress lines, verbose headers, failure blocks, and the summary. Files found while walking a directory are now displayed relative to that scan root instead; flat layouts are unaffected. Closes #61 Co-Authored-By: Claude Sonnet 5 --- SPEC.md | 2 ++ cmd/clitest/root.go | 31 ++++++++++------- internal/display/display_test.go | 11 ++++++ internal/display/output.go | 11 +++--- internal/display/progress.go | 11 +++--- internal/display/verbose.go | 6 ++-- internal/resolve/resolve.go | 34 ++++++++++++++----- internal/resolve/resolve_test.go | 24 +++++++++++++ ..._nested_path_relative_to_scan_root.clitest | 7 ++++ 9 files changed, 101 insertions(+), 36 deletions(-) create mode 100644 test/e2e/resolve/it_shows_nested_path_relative_to_scan_root.clitest diff --git a/SPEC.md b/SPEC.md index 53d9798..62ab3a1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -726,6 +726,8 @@ clitest [options] `` — one or more `.clitest` files, directories, or glob patterns (quoted). Directories are scanned recursively for `*.clitest` files by default. Glob patterns (containing `*`, `?`, or `[`) are expanded by clitest itself. Non-`.clitest` files passed as arguments are skipped with a warning. +When a directory is scanned recursively, files are displayed by their path relative to that directory (e.g. `orders/import/it_handles_order.clitest`), not just the filename — this disambiguates same-named files in different subdirectories. Files sitting directly in the scanned directory show just their filename, unchanged. + ### Options | Flag | Description | diff --git a/cmd/clitest/root.go b/cmd/clitest/root.go index d55beff..abc3cf2 100644 --- a/cmd/clitest/root.go +++ b/cmd/clitest/root.go @@ -71,7 +71,7 @@ type fileResult struct { pass int fail int skip int - file string + display string failures []display.CompactFailure hidden bool } @@ -134,7 +134,7 @@ func runMain(_ *cobra.Command, args []string) error { totalFail += r.fail totalSkip += r.skip if r.fail > 0 { - failedFiles = append(failedFiles, r.file) + failedFiles = append(failedFiles, r.display) } } @@ -149,7 +149,7 @@ func runMain(_ *cobra.Command, args []string) error { // runFiles is the unified execution function for both compact and verbose modes. // It uses ProgressDisplay for the dynamic running-file block at the bottom, // and emits finished file output (compact line or verbose block) permanently above. -func runFiles(files []string, workers int, isTTY bool, w io.Writer) []fileResult { +func runFiles(files []resolve.ScanFile, workers int, isTTY bool, w io.Writer) []fileResult { results := make([]fileResult, len(files)) jobs := make(chan int, len(files)) var wg sync.WaitGroup @@ -169,8 +169,13 @@ func runFiles(files []string, workers int, isTTY bool, w io.Writer) []fileResult maxDynamic = 16 } + displays := make([]string, len(files)) + for i, f := range files { + displays[i] = f.Display + } + pd := display.NewProgressDisplay(w, isTTY, maxDynamic) - pd.Start(files) + pd.Start(displays) for w := 0; w < workers; w++ { wg.Add(1) @@ -191,7 +196,7 @@ func runFiles(files []string, workers int, isTTY bool, w io.Writer) []fileResult if !verbose { for _, r := range results { if len(r.failures) > 0 { - display.PrintFailureDetails(os.Stdout, r.failures, r.file) + display.PrintFailureDetails(os.Stdout, r.failures, r.display) } } } @@ -199,8 +204,8 @@ func runFiles(files []string, workers int, isTTY bool, w io.Writer) []fileResult } // processFile handles parsing, filtering, and execution for a single file. -func processFile(cfg *runConfig, f string, idx int, pd *display.ProgressDisplay) fileResult { - parsed, err := loadAndParse(f, varFlags.values) +func processFile(cfg *runConfig, f resolve.ScanFile, idx int, pd *display.ProgressDisplay) fileResult { + parsed, err := loadAndParse(f.Path, varFlags.values) if err != nil { errOutput := fmt.Sprintf("%s%v%s\n", display.ColorRed, err, display.ColorReset) pd.FileError(idx, errOutput) @@ -211,35 +216,35 @@ func processFile(cfg *runConfig, f string, idx int, pd *display.ProgressDisplay) if cfg.FailFast { cfg.Cancelled.Store(true) } - return fileResult{fail: 1, file: f} + return fileResult{fail: 1, display: f.Display} } if parsed.Directives.Skip { pd.UpdateProgress(idx, 0, 0) pd.FinishFile(idx, true, "") - return fileResult{skip: len(parsed.Entries), file: f} + return fileResult{skip: len(parsed.Entries), display: f.Display} } entries := filter.Entries(parsed, groupFlags, excludeGroupFlags) if len(entries) == 0 { pd.HideFile(idx) - return fileResult{file: f, hidden: true} + return fileResult{display: f.Display, hidden: true} } if verbose { var buf bytes.Buffer vd := display.NewVerboseDisplay(&buf, true) - vd.BeginFile(f) + vd.BeginFile(f.Display) pass, fail, skip := runEntriesVerbose(cfg, vd, pd, idx, entries) vd.EndFile() pd.FinishFile(idx, fail == 0, buf.String()) - return fileResult{pass: pass, fail: fail, skip: skip, file: f} + return fileResult{pass: pass, fail: fail, skip: skip, display: f.Display} } pass, fail, skip, details := runEntriesCompact(cfg, pd, idx, entries) pd.FinishFile(idx, fail == 0, "") - return fileResult{pass: pass, fail: fail, skip: skip, file: f, failures: details} + return fileResult{pass: pass, fail: fail, skip: skip, display: f.Display, failures: details} } diff --git a/internal/display/display_test.go b/internal/display/display_test.go index 98798da..985a2a8 100644 --- a/internal/display/display_test.go +++ b/internal/display/display_test.go @@ -196,6 +196,17 @@ func TestVerboseDisplay_ShowsFileHeader(t *testing.T) { } } +func TestVerboseDisplay_ShowsFileHeaderWithSubdirPath(t *testing.T) { + var buf bytes.Buffer + d := NewVerboseDisplay(&buf, false) + d.BeginFile("orders/import/it_handles_order.clitest") + + output := buf.String() + if !strings.Contains(output, "▶ orders/import/it_handles_order.clitest") { + t.Errorf("expected full display path (not stripped to basename), got:\n%s", output) + } +} + func TestCountLines(t *testing.T) { tests := []struct { input string diff --git a/internal/display/output.go b/internal/display/output.go index b4ce452..e5fb104 100644 --- a/internal/display/output.go +++ b/internal/display/output.go @@ -3,7 +3,6 @@ package display import ( "fmt" "io" - "path/filepath" "sort" "strings" "time" @@ -64,7 +63,7 @@ func PrintHeader(w io.Writer, version string, resolved []ResolvedArg, parallel i fmt.Fprintln(w) } -// PrintSummary prints the test run summary. +// PrintSummary prints the test run summary. failedFiles are display names. func PrintSummary(w io.Writer, totalPass, totalFail, totalSkip, totalWarnings int, failedFiles []string, elapsed time.Duration) { fmt.Fprintf(w, "%s━━━ Summary ━━━%s\n", ColorBold, ColorReset) fmt.Fprintf(w, " %spass: %d%s\n", ColorGreen, totalPass, ColorReset) @@ -77,15 +76,15 @@ func PrintSummary(w io.Writer, totalPass, totalFail, totalSkip, totalWarnings in if totalFail > 0 { fmt.Fprintf(w, " %sfail: %d%s\n", ColorRed, totalFail, ColorReset) for _, f := range failedFiles { - fmt.Fprintf(w, " %s - %s%s\n", ColorRed, filepath.Base(f), ColorReset) + fmt.Fprintf(w, " %s - %s%s\n", ColorRed, f, ColorReset) } } fmt.Fprintf(w, " %stook: %s%s\n", ColorGray, FormatDuration(elapsed), ColorReset) } -// PrintFailureDetails prints detailed failure output for compact mode. -func PrintFailureDetails(w io.Writer, failures []CompactFailure, file string) { - fmt.Fprintf(w, "%s▶ %s%s\n", ColorBold, filepath.Base(file), ColorReset) +// PrintFailureDetails prints detailed failure output for compact mode. name is the display name. +func PrintFailureDetails(w io.Writer, failures []CompactFailure, name string) { + fmt.Fprintf(w, "%s▶ %s%s\n", ColorBold, name, ColorReset) for _, f := range failures { fmt.Fprintf(w, " %s✗%s %s\n", ColorRed, ColorReset, TruncateCmd(f.Command, 60)) for _, msg := range f.Failures { diff --git a/internal/display/progress.go b/internal/display/progress.go index 6d7eb74..b22c99d 100644 --- a/internal/display/progress.go +++ b/internal/display/progress.go @@ -3,7 +3,6 @@ package display import ( "fmt" "io" - "path/filepath" "strings" "sync" "time" @@ -67,13 +66,15 @@ func NewProgressDisplay(w io.Writer, dynamic bool, maxDynamic int) *ProgressDisp return &ProgressDisplay{w: w, dynamic: dynamic, maxDynamic: maxDynamic} } -func (d *ProgressDisplay) Start(files []string) { +// Start initializes the display list. names are the display names shown to +// the user (not necessarily the paths used to read the files). +func (d *ProgressDisplay) Start(names []string) { d.mu.Lock() defer d.mu.Unlock() - d.files = make([]fileState, len(files)) - for i, f := range files { - d.files[i] = fileState{name: filepath.Base(f), status: StatusRun, startTime: time.Now()} + d.files = make([]fileState, len(names)) + for i, name := range names { + d.files[i] = fileState{name: name, status: StatusRun, startTime: time.Now()} } } diff --git a/internal/display/verbose.go b/internal/display/verbose.go index b1c7689..02b4ea3 100644 --- a/internal/display/verbose.go +++ b/internal/display/verbose.go @@ -3,7 +3,6 @@ package display import ( "fmt" "io" - "path/filepath" "strings" ) @@ -21,8 +20,9 @@ func NewVerboseDisplay(w io.Writer, verbose bool) *VerboseDisplay { return &VerboseDisplay{w: w, verbose: verbose} } -func (d *VerboseDisplay) BeginFile(filename string) { - fmt.Fprintf(d.w, "%s▶ %s%s\n", ColorBold, filepath.Base(filename), ColorReset) +// BeginFile prints the file header. name is the display name shown to the user. +func (d *VerboseDisplay) BeginFile(name string) { + fmt.Fprintf(d.w, "%s▶ %s%s\n", ColorBold, name, ColorReset) } func (d *VerboseDisplay) EntryResult(info EntryInfo) { diff --git a/internal/resolve/resolve.go b/internal/resolve/resolve.go index d492875..26878a6 100644 --- a/internal/resolve/resolve.go +++ b/internal/resolve/resolve.go @@ -15,10 +15,19 @@ type ResolvedArg struct { Count int } +// ScanFile is a resolved .clitest file. Path is used to read the file; +// Display is the name shown to the user, which for files found while +// recursively scanning a directory is the path relative to that directory +// (disambiguating same-named files in different subdirectories). +type ScanFile struct { + Path string + Display string +} + // Files resolves CLI arguments into .clitest file paths. // It handles individual files, directories (recursively or not), and glob patterns. // Warnings are returned (not printed) so callers can decide how to display them. -func Files(args []string, recursive bool) (files []string, resolved []ResolvedArg, warnings []string, err error) { +func Files(args []string, recursive bool) (files []ScanFile, resolved []ResolvedArg, warnings []string, err error) { for _, arg := range args { countBefore := len(files) @@ -35,11 +44,11 @@ func Files(args []string, recursive bool) (files []string, resolved []ResolvedAr resolved = append(resolved, ResolvedArg{Input: arg, Count: len(files) - countBefore}) } } - sort.Strings(files) + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) return files, resolved, warnings, nil } -func resolveGlobArg(pattern string, recursive bool, files, warnings []string) (outFiles, outWarnings []string, err error) { +func resolveGlobArg(pattern string, recursive bool, files []ScanFile, warnings []string) (outFiles []ScanFile, outWarnings []string, err error) { matches, err := filepath.Glob(pattern) if err != nil { return nil, warnings, err @@ -59,13 +68,13 @@ func resolveGlobArg(pattern string, recursive bool, files, warnings []string) (o warnings = append(warnings, fmt.Sprintf("Warning: skipping non-.clitest file: %s", m)) continue } - files = append(files, m) + files = append(files, ScanFile{Path: m, Display: filepath.Base(m)}) } } return files, warnings, nil } -func resolvePathArg(arg string, recursive bool, files, warnings []string) (result []string, skipped bool, w []string, err error) { +func resolvePathArg(arg string, recursive bool, files []ScanFile, warnings []string) (result []ScanFile, skipped bool, w []string, err error) { info, err := os.Stat(arg) if err != nil { return nil, false, warnings, err @@ -78,17 +87,21 @@ func resolvePathArg(arg string, recursive bool, files, warnings []string) (resul warnings = append(warnings, fmt.Sprintf("Warning: skipping non-.clitest file: %s", arg)) return files, true, warnings, nil } - return append(files, arg), false, warnings, nil + return append(files, ScanFile{Path: arg, Display: filepath.Base(arg)}), false, warnings, nil } -func collectFromDir(dir string, recursive bool, files []string) ([]string, error) { +func collectFromDir(dir string, recursive bool, files []ScanFile) ([]ScanFile, error) { if recursive { err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() && strings.HasSuffix(path, ".clitest") { - files = append(files, path) + display, relErr := filepath.Rel(dir, path) + if relErr != nil { + display = filepath.Base(path) + } + files = append(files, ScanFile{Path: path, Display: display}) } return nil }) @@ -98,5 +111,8 @@ func collectFromDir(dir string, recursive bool, files []string) ([]string, error if err != nil { return nil, err } - return append(files, matches...), nil + for _, m := range matches { + files = append(files, ScanFile{Path: m, Display: filepath.Base(m)}) + } + return files, nil } diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go index 22e0883..bd309a0 100644 --- a/internal/resolve/resolve_test.go +++ b/internal/resolve/resolve_test.go @@ -67,3 +67,27 @@ func TestFiles_Directory(t *testing.T) { t.Fatalf("expected 2 files (non-recursive), got %d", len(files)) } } + +func TestFiles_DisplayRelativeToScanRoot(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "flat.clitest"), []byte("$ echo hi\n"), 0o644) + os.MkdirAll(filepath.Join(dir, "orders", "import"), 0o755) + os.WriteFile(filepath.Join(dir, "orders", "import", "nested.clitest"), []byte("$ echo hi\n"), 0o644) + + files, _, _, err := Files([]string{dir}, true) + if err != nil { + t.Fatal(err) + } + + displays := make(map[string]bool) + for _, f := range files { + displays[f.Display] = true + } + + if !displays["flat.clitest"] { + t.Errorf("expected flat file displayed as basename, got %v", displays) + } + if !displays[filepath.Join("orders", "import", "nested.clitest")] { + t.Errorf("expected nested file displayed relative to scan root, got %v", displays) + } +} diff --git a/test/e2e/resolve/it_shows_nested_path_relative_to_scan_root.clitest b/test/e2e/resolve/it_shows_nested_path_relative_to_scan_root.clitest new file mode 100644 index 0000000..12cc894 --- /dev/null +++ b/test/e2e/resolve/it_shows_nested_path_relative_to_scan_root.clitest @@ -0,0 +1,7 @@ +# Nested files are displayed by their path relative to the scan root, not just the basename +./clitest --no-parallel --no-color test/_fixtures/resolve +EXIT 0 +[asserts] +stdout contains "deep/deep.clitest" +stdout contains "hello.clitest" +stdout not contains "- deep.clitest ("