diff --git a/README.md b/README.md index bacb3fd6..fa619e6a 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Version](https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go)](https://go.dev - [Commit Conventions](#commit-conventions) - [Testing Requirements](#testing-requirements) - [Advanced Topics](#advanced-topics) + - [Multi-Repo Discovery](#multi-repo-discovery) - [Spec-Driven Development](#spec-driven-development) - [Delta Specifications](#delta-specifications) - [Validation Rules](#validation-rules) @@ -1028,6 +1029,79 @@ go fmt ./... ## Advanced Topics +### Multi-Repo Discovery + +Spectr supports mono-repo setups with nested git repositories, each with their +own `spectr/` directory. + +#### Discovery Behavior + +When you run any Spectr command, it automatically walks up from your current +working directory to find all `spectr/` directories: + +- **Git isolation**: Discovery stops at `.git` boundaries - each git repository + is isolated +- **Aggregated results**: Commands like `list`, `validate`, `view` aggregate + results from all discovered roots +- **Root prefix**: In multi-root scenarios, items are prefixed with their + relative path: `[../project] add-feature` +- **Single-root compatibility**: When only one root is found, output is + identical to previous behavior (no prefixes) + +**Example directory structure:** + +```text +mono-repo/ +├── .git/ +├── spectr/ # Root repo's spectr +│ └── changes/ +├── packages/ +│ └── auth/ +│ ├── .git/ # Nested git repo +│ └── spectr/ # Auth package's spectr +│ └── changes/ +└── services/ + └── api/ + ├── .git/ # Another nested git repo + └── spectr/ # API service's spectr + └── changes/ +```text + +Running `spectr list` from `mono-repo/` shows changes from all three roots. + +#### SPECTR_ROOT Environment Variable + +Override automatic discovery by setting `SPECTR_ROOT`: + +```bash +# Use explicit spectr root +SPECTR_ROOT=/path/to/project spectr list + +# Relative paths work too +SPECTR_ROOT=../other-project spectr validate --all + +# Useful in scripts/CI for explicit control +export SPECTR_ROOT=/workspace/my-project +spectr list +spectr validate my-change +```text + +**Behavior:** + +- When set, uses ONLY the specified root (skips automatic discovery) +- Errors if the path doesn't contain a `spectr/` directory +- Useful for CI/CD pipelines or scripts that need deterministic behavior + +#### TUI Path Copying + +When selecting items in interactive mode (Enter key), Spectr copies the full +path relative to your cwd: + +- Single root: `spectr/changes/add-feature/proposal.md` +- Nested root: `../project/spectr/changes/add-feature/proposal.md` + +This enables direct navigation with `@` file references in AI coding assistants. + ### Spec-Driven Development Spectr implements a **three-stage workflow** for managing changes: diff --git a/cmd/discovery.go b/cmd/discovery.go new file mode 100644 index 00000000..03c5b056 --- /dev/null +++ b/cmd/discovery.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + + "github.com/connerohnesorge/spectr/internal/discovery" +) + +// GetDiscoveredRoots returns all discovered spectr roots from the current +// working directory. It wraps discovery.FindSpectrRoots with cwd handling. +func GetDiscoveredRoots() ([]discovery.SpectrRoot, error) { + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf( + "failed to get current directory: %w", + err, + ) + } + + roots, err := discovery.FindSpectrRoots(cwd) + if err != nil { + return nil, fmt.Errorf( + "failed to discover spectr roots: %w", + err, + ) + } + + return roots, nil +} + +// GetSingleRoot returns the first discovered root, or an error if no roots +// are found. This is useful for commands that operate on a single root. +func GetSingleRoot() (discovery.SpectrRoot, error) { + roots, err := GetDiscoveredRoots() + if err != nil { + return discovery.SpectrRoot{}, err + } + + if len(roots) == 0 { + return discovery.SpectrRoot{}, errors.New( + "no spectr directory found\nHint: Run 'spectr init' to initialize Spectr", + ) + } + + return roots[0], nil +} + +// HasMultipleRoots returns true if there are multiple discovered roots. +func HasMultipleRoots(roots []discovery.SpectrRoot) bool { + return len(roots) > 1 +} diff --git a/cmd/list.go b/cmd/list.go index fac1f0df..6dd251e7 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -69,7 +69,22 @@ func (c *ListCmd) Run() error { } } - // Get current working directory as the project path + // Discover all spectr roots + roots, err := GetDiscoveredRoots() + if err != nil { + return fmt.Errorf( + "failed to discover spectr roots: %w", + err, + ) + } + + if len(roots) == 0 { + fmt.Println("No spectr directories found.") + + return nil + } + + // Get current working directory for interactive mode projectPath, err := os.Getwd() if err != nil { return fmt.Errorf( @@ -78,28 +93,30 @@ func (c *ListCmd) Run() error { ) } - // Create lister instance for the project - lister := list.NewLister(projectPath) + // Create multi-root lister + multiLister := list.NewMultiRootLister(roots) + hasMultipleRoots := multiLister.HasMultipleRoots() // Route to appropriate listing function if c.All { - return c.listAll(lister, projectPath) + return c.listAllMulti(multiLister, projectPath, hasMultipleRoots) } if c.Specs { - return c.listSpecs(lister, projectPath) + return c.listSpecsMulti(multiLister, projectPath, hasMultipleRoots) } - return c.listChanges(lister, projectPath) + return c.listChangesMulti(multiLister, projectPath, hasMultipleRoots) } -// listChanges retrieves and displays changes in the requested format. +// listChangesMulti retrieves and displays changes from all discovered roots. // It handles interactive mode, JSON, long, and default text formats. -func (c *ListCmd) listChanges( - lister *list.Lister, +func (c *ListCmd) listChangesMulti( + multiLister *list.MultiRootLister, projectPath string, + hasMultipleRoots bool, ) error { - // Retrieve all changes from the project - changes, err := lister.ListChanges() + // Retrieve all changes from all roots + changes, err := multiLister.ListChanges() if err != nil { return fmt.Errorf( "failed to list changes: %w", @@ -109,38 +126,7 @@ func (c *ListCmd) listChanges( // Handle interactive mode - shows a navigable table if c.Interactive { - if len(changes) == 0 { - fmt.Println("No changes found.") - - return nil - } - - archiveID, prID, err := list.RunInteractiveChanges( - changes, - projectPath, - c.Stdout, - ) - if err != nil { - return err - } - - // If an archive was requested, run the archive workflow - if archiveID != "" { - return c.runArchiveWorkflow( - archiveID, - projectPath, - ) - } - - // If PR mode was requested, run the PR workflow - if prID != "" { - return c.runPRWorkflow( - prID, - projectPath, - ) - } - - return nil + return c.handleInteractiveChanges(changes, projectPath) } // Format output based on flags @@ -148,22 +134,22 @@ func (c *ListCmd) listChanges( switch { case c.JSON: // JSON format for machine consumption - var err error - output, err = list.FormatChangesJSON( + var jsonErr error + output, jsonErr = list.FormatChangesJSON( changes, ) - if err != nil { + if jsonErr != nil { return fmt.Errorf( "failed to format JSON: %w", - err, + jsonErr, ) } case c.Long: // Long format with detailed information - output = list.FormatChangesLong(changes) + output = list.FormatChangesLongMulti(changes, list.NewFormatMode(hasMultipleRoots)) default: - // Default text format - simple ID list - output = list.FormatChangesText(changes) + // Default text format - simple ID list (with root prefix if multi-root) + output = list.FormatChangesTextMulti(changes, list.NewFormatMode(hasMultipleRoots)) } // Display the formatted output @@ -172,6 +158,52 @@ func (c *ListCmd) listChanges( return nil } +// handleInteractiveChanges runs the interactive TUI for changes +// and handles archive/PR workflow requests. +func (c *ListCmd) handleInteractiveChanges( + changes []list.ChangeInfo, + projectPath string, +) error { + if len(changes) == 0 { + fmt.Println("No changes found.") + + return nil + } + + archiveID, archiveRootPath, prID, prRootPath, err := list.RunInteractiveChanges( + changes, + projectPath, + c.Stdout, + ) + if err != nil { + return err + } + + // If an archive was requested, run the archive workflow + if archiveID != "" { + // Use the change's root path if available, fallback to cwd + rootPath := archiveRootPath + if rootPath == "" { + rootPath = projectPath + } + + return c.runArchiveWorkflow(archiveID, rootPath) + } + + // If PR mode was requested, run the PR workflow + if prID != "" { + // Use the change's root path if available, fallback to cwd + rootPath := prRootPath + if rootPath == "" { + rootPath = projectPath + } + + return c.runPRWorkflow(prID, rootPath) + } + + return nil +} + // runArchiveWorkflow executes the archive workflow for a change. func (*ListCmd) runArchiveWorkflow( changeID, projectPath string, @@ -229,14 +261,15 @@ func (*ListCmd) runPRWorkflow( return nil } -// listSpecs retrieves and displays specifications in the requested format. +// listSpecsMulti retrieves and displays specifications from all discovered roots. // It handles interactive mode, JSON, long, and default text formats. -func (c *ListCmd) listSpecs( - lister *list.Lister, +func (c *ListCmd) listSpecsMulti( + multiLister *list.MultiRootLister, projectPath string, + hasMultipleRoots bool, ) error { - // Retrieve all specifications from the project - specs, err := lister.ListSpecs() + // Retrieve all specifications from all roots + specs, err := multiLister.ListSpecs() if err != nil { return fmt.Errorf( "failed to list specs: %w", @@ -264,20 +297,20 @@ func (c *ListCmd) listSpecs( switch { case c.JSON: // JSON format for machine consumption - var err error - output, err = list.FormatSpecsJSON(specs) - if err != nil { + var jsonErr error + output, jsonErr = list.FormatSpecsJSON(specs) + if jsonErr != nil { return fmt.Errorf( "failed to format JSON: %w", - err, + jsonErr, ) } case c.Long: // Long format with detailed information - output = list.FormatSpecsLong(specs) + output = list.FormatSpecsLongMulti(specs, list.NewFormatMode(hasMultipleRoots)) default: // Default text format - simple ID list - output = list.FormatSpecsText(specs) + output = list.FormatSpecsTextMulti(specs, list.NewFormatMode(hasMultipleRoots)) } // Display the formatted output @@ -286,14 +319,15 @@ func (c *ListCmd) listSpecs( return nil } -// listAll retrieves and displays both changes and specs in unified format. +// listAllMulti retrieves and displays both changes and specs from all roots. // It handles interactive mode, JSON, long, and default text formats. -func (c *ListCmd) listAll( - lister *list.Lister, +func (c *ListCmd) listAllMulti( + multiLister *list.MultiRootLister, projectPath string, + hasMultipleRoots bool, ) error { - // Retrieve all items (changes and specs) from the project - items, err := lister.ListAll(nil) + // Retrieve all items (changes and specs) from all roots + items, err := multiLister.ListAll(nil) if err != nil { return fmt.Errorf( "failed to list all items: %w", @@ -321,20 +355,20 @@ func (c *ListCmd) listAll( switch { case c.JSON: // JSON format for machine consumption - var err error - output, err = list.FormatAllJSON(items) - if err != nil { + var jsonErr error + output, jsonErr = list.FormatAllJSON(items) + if jsonErr != nil { return fmt.Errorf( "failed to format JSON: %w", - err, + jsonErr, ) } case c.Long: // Long format with detailed information - output = list.FormatAllLong(items) + output = list.FormatAllLongMulti(items, list.NewFormatMode(hasMultipleRoots)) default: // Default text format - simple ID list with type indicators - output = list.FormatAllText(items) + output = list.FormatAllTextMulti(items, list.NewFormatMode(hasMultipleRoots)) } // Display the formatted output diff --git a/cmd/root.go b/cmd/root.go index 4616dd84..549f8644 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -30,37 +30,54 @@ type CLI struct { } // AfterApply is called by Kong after parsing flags but before running the command. -// It synchronizes task statuses from tasks.jsonc to tasks.md for all active changes. +// It synchronizes task statuses from tasks.jsonc to tasks.md for all active changes +// across all discovered spectr roots. func (c *CLI) AfterApply() error { if c.NoSync { return nil } - projectRoot, err := os.Getwd() + // Discover all spectr roots + roots, err := GetDiscoveredRoots() if err != nil { // Log error but don't block command fmt.Fprintf( os.Stderr, - "sync: failed to get working directory: %v\n", + "sync: failed to discover spectr roots: %v\n", err, ) return nil } - // Check if spectr/ directory exists (not initialized = skip) - spectrDir := filepath.Join( - projectRoot, - "spectr", - ) - if _, err := os.Stat(spectrDir); os.IsNotExist( - err, - ) { + // If no roots found, skip sync (not initialized) + if len(roots) == 0 { return nil } - return sync.SyncAllActiveChanges( - projectRoot, - c.Verbose, - ) + // Sync all active changes across all discovered roots + for _, root := range roots { + // Check if spectr/ directory exists for this root + spectrDir := filepath.Join(root.Path, "spectr") + if _, statErr := os.Stat(spectrDir); os.IsNotExist(statErr) { + continue + } + + syncErr := sync.SyncAllActiveChanges(root.Path, c.Verbose) + if syncErr == nil { + continue + } + + // Log error but continue with other roots + if c.Verbose { + fmt.Fprintf( + os.Stderr, + "sync: failed for %s: %v\n", + root.RelativeTo, + syncErr, + ) + } + } + + return nil } diff --git a/cmd/validate.go b/cmd/validate.go index 99aba08c..2a1bdf1a 100644 --- a/cmd/validate.go +++ b/cmd/validate.go @@ -11,6 +11,8 @@ import ( "github.com/connerohnesorge/spectr/internal/validation" ) +// Note: GetDiscoveredRoots() is defined in cmd/discovery.go + // ValidateCmd represents the validate command type ValidateCmd struct { ItemName *string `arg:"" optional:"" predictor:"item"` @@ -115,14 +117,25 @@ func (c *ValidateCmd) runDirectValidation( // runBulkValidation validates multiple items based on flags func (c *ValidateCmd) runBulkValidation( - projectPath string, + _ string, ) error { validator := validation.NewValidator() + // Discover all spectr roots + roots, err := GetDiscoveredRoots() + if err != nil { + return fmt.Errorf( + "failed to discover spectr roots: %w", + err, + ) + } + + if len(roots) == 0 { + return c.handleNoItems() + } + // Determine what to validate - items, err := c.getItemsToValidate( - projectPath, - ) + items, err := c.getItemsToValidateMultiRoot(roots) if err != nil { return err } @@ -138,10 +151,11 @@ func (c *ValidateCmd) runBulkValidation( ) // Print results + hasMultipleRoots := len(roots) > 1 if c.JSON { validation.PrintBulkJSONResults(results) } else { - validation.PrintBulkHumanResults(results) + validation.PrintBulkHumanResultsMulti(results, hasMultipleRoots) } if hasFailures { @@ -153,21 +167,17 @@ func (c *ValidateCmd) runBulkValidation( return nil } -// getItemsToValidate returns the items to validate based on flags -func (c *ValidateCmd) getItemsToValidate( - projectPath string, +// getItemsToValidateMultiRoot returns the items to validate from all roots. +func (c *ValidateCmd) getItemsToValidateMultiRoot( + roots []discovery.SpectrRoot, ) ([]validation.ValidationItem, error) { switch { case c.All: - return validation.GetAllItems(projectPath) + return validation.GetAllItemsMultiRoot(roots) case c.Changes: - return validation.GetChangeItems( - projectPath, - ) + return validation.GetChangeItemsMultiRoot(roots) case c.Specs: - return validation.GetSpecItems( - projectPath, - ) + return validation.GetSpecItemsMultiRoot(roots) default: return nil, nil } diff --git a/cmd/view.go b/cmd/view.go index 11922a0c..a1c18b0c 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -4,7 +4,6 @@ package cmd import ( "fmt" - "os" "github.com/connerohnesorge/spectr/internal/view" ) @@ -34,31 +33,39 @@ type ViewCmd struct { } // Run executes the view command. -// It collects dashboard data from the project and formats the output +// It collects dashboard data from all discovered spectr roots and formats the output // based on the JSON flag (either human-readable text or JSON). -// Returns an error if the spectr directory is missing or if +// Returns an error if no spectr directories are found or if // discovery/parsing fails. func (c *ViewCmd) Run() error { - // Get current working directory as the project path - projectPath, err := os.Getwd() + // Discover all spectr roots + roots, err := GetDiscoveredRoots() if err != nil { return fmt.Errorf( - "failed to get current directory: %w", + "failed to discover spectr roots: %w", err, ) } - // Collect dashboard data from the project - data, err := view.CollectData(projectPath) - if err != nil { - // Handle missing spectr directory error - if os.IsNotExist(err) { - return fmt.Errorf( - "spectr directory not found: %w\n"+ - "Hint: Run 'spectr init' to initialize Spectr", - err, - ) + // If no roots found, show empty dashboard (graceful degradation) + if len(roots) == 0 { + // Return empty dashboard data + data := &view.DashboardData{ + Summary: view.SummaryMetrics{}, + ActiveChanges: make([]view.ChangeProgress, 0), + CompletedChanges: make([]view.CompletedChange, 0), + Specs: make([]view.SpecInfo, 0), } + + output := view.FormatDashboardText(data) + fmt.Println(output) + + return nil + } + + // Collect dashboard data from all roots + data, err := view.CollectDataMultiRoot(roots) + if err != nil { // Handle other discovery/parsing failures return fmt.Errorf( "failed to collect dashboard data: %w", diff --git a/examples/archive/spectr/specs/sample-feature/spec.md b/examples/archive/spectr/specs/sample-feature/spec.md index ebc74248..413ac0f8 100644 --- a/examples/archive/spectr/specs/sample-feature/spec.md +++ b/examples/archive/spectr/specs/sample-feature/spec.md @@ -1,3 +1,15 @@ # Sample Feature Base spec for sample feature capability. + +## Requirements + +### Requirement: Sample Capability + +The system SHALL provide a sample feature for demonstration purposes. + +#### Scenario: Basic Usage + +- GIVEN a sample feature is available +- WHEN the user invokes it +- THEN the feature operates as expected diff --git a/examples/validate/spectr/specs/test-feature/spec.md b/examples/validate/spectr/specs/test-feature/spec.md index 97956deb..f57664da 100644 --- a/examples/validate/spectr/specs/test-feature/spec.md +++ b/examples/validate/spectr/specs/test-feature/spec.md @@ -1,3 +1,15 @@ # Test Feature Base spec for test-feature capability. + +## Requirements + +### Requirement: Test Capability + +The system SHALL provide a test feature for validation testing purposes. + +#### Scenario: Basic Validation + +- GIVEN a test feature spec exists +- WHEN validation is run +- THEN the spec passes validation diff --git a/internal/discovery/roots.go b/internal/discovery/roots.go new file mode 100644 index 00000000..810d06f3 --- /dev/null +++ b/internal/discovery/roots.go @@ -0,0 +1,392 @@ +package discovery + +import ( + "fmt" + "os" + "path/filepath" + "sort" +) + +const ( + // spectrDirName is the standard name for spectr directories. + spectrDirName = "spectr" + + // gitDirName is the standard name for git directories. + gitDirName = ".git" + + // maxDiscoveryDepth limits how deep downward discovery will traverse. + maxDiscoveryDepth = 10 +) + +// SpectrRoot represents a discovered spectr/ directory with its location context. +type SpectrRoot struct { + // Path is the absolute path to the directory containing spectr/ + // (e.g., /home/user/mono/project) + Path string + + // RelativeTo is the path relative to the current working directory + // (e.g., "../project" or ".") + RelativeTo string + + // GitRoot is the absolute path to the parent .git directory + // (e.g., /home/user/mono) + GitRoot string +} + +// SpectrDir returns the absolute path to the spectr/ directory. +func (r SpectrRoot) SpectrDir() string { + return filepath.Join(r.Path, spectrDirName) +} + +// ChangesDir returns the absolute path to the spectr/changes/ directory. +func (r SpectrRoot) ChangesDir() string { + return filepath.Join(r.Path, "spectr", "changes") +} + +// SpecsDir returns the absolute path to the spectr/specs/ directory. +func (r SpectrRoot) SpecsDir() string { + return filepath.Join(r.Path, "spectr", "specs") +} + +// DisplayName returns a human-readable name for the root. +// If RelativeTo is ".", returns the directory name; otherwise returns RelativeTo. +func (r SpectrRoot) DisplayName() string { + if r.RelativeTo == "." { + return filepath.Base(r.Path) + } + + return r.RelativeTo +} + +// FindSpectrRoots discovers all spectr/ directories by walking up the directory +// tree from the given current working directory, stopping at git repository +// boundaries. +// +// If SPECTR_ROOT environment variable is set, it returns only that root +// (and validates it exists). +// +// The function returns roots in order from closest to cwd to furthest. +func FindSpectrRoots(cwd string) ([]SpectrRoot, error) { + // Check for SPECTR_ROOT env var override + if envRoot := os.Getenv("SPECTR_ROOT"); envRoot != "" { + return findSpectrRootFromEnv(envRoot, cwd) + } + + return findSpectrRootsFromCwd(cwd) +} + +// findSpectrRootFromEnv handles the SPECTR_ROOT environment variable case. +func findSpectrRootFromEnv(envRoot, cwd string) ([]SpectrRoot, error) { + // Make path absolute if relative + absPath := envRoot + if !filepath.IsAbs(envRoot) { + absPath = filepath.Join(cwd, envRoot) + } + absPath = filepath.Clean(absPath) + + // Validate spectr/ directory exists + spectrDir := filepath.Join(absPath, spectrDirName) + info, err := os.Stat(spectrDir) + if os.IsNotExist(err) { + return nil, fmt.Errorf( + "SPECTR_ROOT path does not contain spectr/ directory: %s", + absPath, + ) + } + if err != nil { + return nil, fmt.Errorf( + "failed to check SPECTR_ROOT path: %w", + err, + ) + } + if !info.IsDir() { + return nil, fmt.Errorf( + "SPECTR_ROOT path does not contain spectr/ directory: %s", + absPath, + ) + } + + // Calculate relative path from cwd + relPath, err := filepath.Rel(cwd, absPath) + if err != nil { + relPath = absPath // Fallback to absolute if rel fails + } + + // Find git root for this path + gitRoot := findGitRoot(absPath) + + return []SpectrRoot{ + { + Path: absPath, + RelativeTo: relPath, + GitRoot: gitRoot, + }, + }, nil +} + +// findSpectrRootsFromCwd walks up from cwd to find all spectr/ directories, +// stopping at git boundaries, and also searches downward from cwd (or git root) +// to find nested spectr/ directories in subdirectories. +func findSpectrRootsFromCwd(cwd string) ([]SpectrRoot, error) { + var roots []SpectrRoot + + // Make cwd absolute + absCwd, err := filepath.Abs(cwd) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %w", err) + } + + // Find the git root first to establish the boundary + gitRoot := findGitRoot(absCwd) + + // 1. Upward discovery: Walk up from cwd to git root (or filesystem root if no git) + current := absCwd + for { + // Check if spectr/ directory exists at this level + spectrDir := filepath.Join(current, spectrDirName) + info, err := os.Stat(spectrDir) + if err == nil && info.IsDir() { + // Calculate relative path from original cwd + relPath, relErr := filepath.Rel(absCwd, current) + if relErr != nil { + relPath = current // Fallback to absolute + } + + roots = append(roots, SpectrRoot{ + Path: current, + RelativeTo: relPath, + GitRoot: gitRoot, + }) + } + + // Stop if we've reached the git root + if gitRoot != "" && current == gitRoot { + break + } + + // Stop if we've reached the filesystem root + parent := filepath.Dir(current) + if parent == current { + break + } + + current = parent + } + + // 2. Downward discovery: Search for nested spectr/ directories from cwd + roots = appendDownwardRoots(roots, absCwd, gitRoot) + + // 3. Deduplicate roots (upward and downward may find same directories) + roots = deduplicateRoots(roots) + + // 4. Sort by distance from cwd (closest first) + roots = sortRootsByDistance(roots, absCwd) + + return roots, nil +} + +// appendDownwardRoots performs downward discovery and appends results to roots. +// Downward discovery happens when: +// a) We're NOT inside a git repository (gitRoot is empty), OR +// b) We ARE at the git root itself (to find nested subprojects in monorepos) +// This enables monorepo support where the root contains subprojects with +// their own .git and spectr/ directories. +func appendDownwardRoots(existingRoots []SpectrRoot, absCwd, gitRoot string) []SpectrRoot { + if gitRoot != "" && absCwd != gitRoot { + return existingRoots + } + + downwardRoots, err := findSpectrRootsDownward(absCwd, absCwd, maxDiscoveryDepth) + // Ignore downward discovery errors - upward discovery already succeeded + if err == nil { + return append(existingRoots, downwardRoots...) + } + + return existingRoots +} + +// findGitRoot walks up from the given path to find the nearest .git directory. +// Returns empty string if no git root is found. +func findGitRoot(startPath string) string { + current := startPath + for { + gitDir := filepath.Join(current, gitDirName) + info, err := os.Stat(gitDir) + if err == nil && info.IsDir() { + return current + } + + // Also check for git worktree files (where .git is a file, not dir) + if err == nil && !info.IsDir() { + return current + } + + parent := filepath.Dir(current) + if parent == current { + // Reached filesystem root without finding .git + return "" + } + + current = parent + } +} + +// shouldSkipDirectory returns true if the directory should be skipped during downward discovery. +func shouldSkipDirectory(dirName string) bool { + skipDirs := []string{gitDirName, "node_modules", "vendor", "target", "dist", "build"} + for _, skip := range skipDirs { + if dirName == skip { + return true + } + } + + return false +} + +// findSpectrRootsDownward searches for spectr/ directories in subdirectories, +// descending from startPath up to maxDepth levels deep. It discovers nested +// repositories (directories with .git) and their spectr/ directories. +// +// This complements upward discovery to support mono-repo structures where +// multiple nested projects each have their own .git and spectr/ directories. +// +// Optimization: Uses os.ReadDir directly instead of filepath.WalkDir to avoid +// visiting every file in the tree. We only traverse directories and do minimal +// syscalls. +func findSpectrRootsDownward(startPath, cwd string, maxDepth int) ([]SpectrRoot, error) { + var roots []SpectrRoot + absStartPath, err := filepath.Abs(startPath) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %w", err) + } + + // Recursive closure for traversal + var walk func(path string, depth int) + walk = func(path string, depth int) { + if depth > maxDepth { + return + } + + entries, err := os.ReadDir(path) + if err != nil { + return + } + + hasSpectrDir, hasGitEntry, subdirs := scanDirectoryEntries(entries) + + // Check if this is a valid root + if hasSpectrDir && hasGitEntry { + relPath, err := filepath.Rel(cwd, path) + if err != nil { + relPath = path + } + + roots = append(roots, SpectrRoot{ + Path: path, + RelativeTo: relPath, + GitRoot: path, + }) + } + + // Stop at git boundary unless it's the start path + if hasGitEntry && path != absStartPath { + return + } + + // Recurse + for _, sub := range subdirs { + walk(filepath.Join(path, sub), depth+1) + } + } + + // Start traversal + walk(absStartPath, 0) + + return roots, nil +} + +// scanDirectoryEntries processes directory entries to find key files and subdirectories. +func scanDirectoryEntries(entries []os.DirEntry) (hasSpectrDir, hasGitEntry bool, subdirs []string) { + for _, entry := range entries { + name := entry.Name() + + if name == spectrDirName && entry.IsDir() { + hasSpectrDir = true + } + + if name == gitDirName { + hasGitEntry = true + } + + if !entry.IsDir() || shouldSkipDirectory(name) { + continue + } + + if name != spectrDirName && name != gitDirName { + subdirs = append(subdirs, name) + } + } + + return hasSpectrDir, hasGitEntry, subdirs +} + +// deduplicateRoots removes duplicate SpectrRoot entries based on their Path field. +// Preserves the order of first occurrence. +func deduplicateRoots(roots []SpectrRoot) []SpectrRoot { + if len(roots) == 0 { + return roots + } + + seen := make(map[string]bool) + result := make([]SpectrRoot, 0, len(roots)) + + for _, root := range roots { + if !seen[root.Path] { + seen[root.Path] = true + result = append(result, root) + } + } + + return result +} + +// sortRootsByDistance sorts SpectrRoot entries by their relative path length from cwd. +// Roots closer to cwd (shorter relative paths) appear first. +// This ensures that when discovering both upward and downward, the closest root is prioritized. +func sortRootsByDistance(roots []SpectrRoot, cwd string) []SpectrRoot { + if len(roots) <= 1 { + return roots + } + + // Create a copy to avoid modifying the original slice + sorted := make([]SpectrRoot, len(roots)) + copy(sorted, roots) + + // Sort by the length of the relative path + // filepath.Rel returns the shortest path, so shorter = closer + sort.Slice(sorted, func(i, j int) bool { + relI, errI := filepath.Rel(cwd, sorted[i].Path) + relJ, errJ := filepath.Rel(cwd, sorted[j].Path) + + // If there's an error calculating relative path, fall back to comparing paths + if errI != nil || errJ != nil { + return sorted[i].Path < sorted[j].Path + } + + // Compare by number of path separators (shorter = closer) + // "." has 0 separators (closest) + // ".." has 1 separator + // "../.." has 2 separators, etc. + depthI := len(filepath.SplitList(relI)) + depthJ := len(filepath.SplitList(relJ)) + + if depthI == depthJ { + // If same depth, sort alphabetically for consistency + return relI < relJ + } + + return depthI < depthJ + }) + + return sorted +} diff --git a/internal/discovery/roots_test.go b/internal/discovery/roots_test.go new file mode 100644 index 00000000..e9825d43 --- /dev/null +++ b/internal/discovery/roots_test.go @@ -0,0 +1,1401 @@ +package discovery + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFindSpectrRoots_SingleRoot(t *testing.T) { + // Create a temp directory structure with a single spectr/ dir + tmpDir := t.TempDir() + + // Create spectr/ directory + spectrDir := filepath.Join(tmpDir, "spectr") + if err := os.MkdirAll(spectrDir, 0o755); err != nil { + t.Fatalf("failed to create spectr dir: %v", err) + } + + // Create .git directory to establish boundary + gitDir := filepath.Join(tmpDir, ".git") + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatalf("failed to create .git dir: %v", err) + } + + roots, err := FindSpectrRoots(tmpDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(roots) != 1 { + t.Fatalf("expected 1 root, got %d", len(roots)) + } + + if roots[0].Path != tmpDir { + t.Errorf("expected Path %s, got %s", tmpDir, roots[0].Path) + } + + if roots[0].RelativeTo != "." { + t.Errorf("expected RelativeTo '.', got %s", roots[0].RelativeTo) + } + + if roots[0].GitRoot != tmpDir { + t.Errorf("expected GitRoot %s, got %s", tmpDir, roots[0].GitRoot) + } +} + +func TestFindSpectrRoots_MultipleRoots(t *testing.T) { + // Create a nested directory structure: + // tmpDir/ + // .git/ + // spectr/ <- root 2 (parent) + // project/ + // spectr/ <- root 1 (child, closer to cwd) + // src/ <- cwd + tmpDir := t.TempDir() + + // Create .git directory at root + gitDir := filepath.Join(tmpDir, ".git") + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatalf("failed to create .git dir: %v", err) + } + + // Create parent spectr/ + parentSpectr := filepath.Join(tmpDir, "spectr") + if err := os.MkdirAll(parentSpectr, 0o755); err != nil { + t.Fatalf("failed to create parent spectr dir: %v", err) + } + + // Create project/spectr/ + projectSpectr := filepath.Join(tmpDir, "project", "spectr") + if err := os.MkdirAll(projectSpectr, 0o755); err != nil { + t.Fatalf("failed to create project spectr dir: %v", err) + } + + // Create src/ as cwd + srcDir := filepath.Join(tmpDir, "project", "src") + if err := os.MkdirAll(srcDir, 0o755); err != nil { + t.Fatalf("failed to create src dir: %v", err) + } + + roots, err := FindSpectrRoots(srcDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(roots) != 2 { + t.Fatalf("expected 2 roots, got %d", len(roots)) + } + + // First root should be the closest one (project/) + projectDir := filepath.Join(tmpDir, "project") + if roots[0].Path != projectDir { + t.Errorf("expected first root Path %s, got %s", projectDir, roots[0].Path) + } + if roots[0].RelativeTo != ".." { + t.Errorf("expected first root RelativeTo '..', got %s", roots[0].RelativeTo) + } + + // Second root should be the parent (tmpDir) + if roots[1].Path != tmpDir { + t.Errorf("expected second root Path %s, got %s", tmpDir, roots[1].Path) + } +} + +func TestFindSpectrRoots_GitBoundary(t *testing.T) { + // Create a structure where spectr/ exists outside the git boundary + // tmpDir/ + // spectr/ <- should NOT be found (outside git boundary) + // repo/ + // .git/ + // spectr/ <- should be found + tmpDir := t.TempDir() + + // Create outer spectr/ (outside git boundary) + outerSpectr := filepath.Join(tmpDir, "spectr") + if err := os.MkdirAll(outerSpectr, 0o755); err != nil { + t.Fatalf("failed to create outer spectr dir: %v", err) + } + + // Create repo/.git/ + repoGit := filepath.Join(tmpDir, "repo", ".git") + if err := os.MkdirAll(repoGit, 0o755); err != nil { + t.Fatalf("failed to create repo .git dir: %v", err) + } + + // Create repo/spectr/ + repoSpectr := filepath.Join(tmpDir, "repo", "spectr") + if err := os.MkdirAll(repoSpectr, 0o755); err != nil { + t.Fatalf("failed to create repo spectr dir: %v", err) + } + + repoDir := filepath.Join(tmpDir, "repo") + roots, err := FindSpectrRoots(repoDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(roots) != 1 { + t.Fatalf("expected 1 root (git boundary should stop discovery), got %d", len(roots)) + } + + if roots[0].Path != repoDir { + t.Errorf("expected Path %s, got %s", repoDir, roots[0].Path) + } +} + +func TestFindSpectrRoots_EnvVarOverride(t *testing.T) { + tmpDir := t.TempDir() + + // Create spectr/ directory + spectrDir := filepath.Join(tmpDir, "spectr") + if err := os.MkdirAll(spectrDir, 0o755); err != nil { + t.Fatalf("failed to create spectr dir: %v", err) + } + + // Create another directory to use as cwd + otherDir := filepath.Join(tmpDir, "other") + if err := os.MkdirAll(otherDir, 0o755); err != nil { + t.Fatalf("failed to create other dir: %v", err) + } + + // Set SPECTR_ROOT env var + t.Setenv("SPECTR_ROOT", tmpDir) + + roots, err := FindSpectrRoots(otherDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(roots) != 1 { + t.Fatalf("expected 1 root from env var, got %d", len(roots)) + } + + if roots[0].Path != tmpDir { + t.Errorf("expected Path %s, got %s", tmpDir, roots[0].Path) + } +} + +func TestFindSpectrRoots_EnvVarInvalidPath(t *testing.T) { + tmpDir := t.TempDir() + + // Create directory without spectr/ + noSpectrDir := filepath.Join(tmpDir, "nope") + if err := os.MkdirAll(noSpectrDir, 0o755); err != nil { + t.Fatalf("failed to create dir: %v", err) + } + + // Set SPECTR_ROOT to invalid path + t.Setenv("SPECTR_ROOT", noSpectrDir) + + _, err := FindSpectrRoots(tmpDir) + if err == nil { + t.Fatal("expected error for invalid SPECTR_ROOT, got nil") + } + + expectedErrMsg := "SPECTR_ROOT path does not contain spectr/ directory" + if !contains(err.Error(), expectedErrMsg) { + t.Errorf("expected error containing %q, got %q", expectedErrMsg, err.Error()) + } +} + +func TestFindSpectrRoots_NoRootsFound(t *testing.T) { + tmpDir := t.TempDir() + + // Create .git to establish boundary (no spectr/) + gitDir := filepath.Join(tmpDir, ".git") + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatalf("failed to create .git dir: %v", err) + } + + roots, err := FindSpectrRoots(tmpDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(roots) != 0 { + t.Errorf("expected 0 roots, got %d", len(roots)) + } +} + +func TestFindSpectrRoots_FromSubdirectory(t *testing.T) { + // Test running from deep within a project + // tmpDir/ + // .git/ + // spectr/ + // src/ + // pkg/ + // internal/ <- cwd + tmpDir := t.TempDir() + + // Create .git + gitDir := filepath.Join(tmpDir, ".git") + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatalf("failed to create .git dir: %v", err) + } + + // Create spectr/ + spectrDir := filepath.Join(tmpDir, "spectr") + if err := os.MkdirAll(spectrDir, 0o755); err != nil { + t.Fatalf("failed to create spectr dir: %v", err) + } + + // Create nested cwd + deepDir := filepath.Join(tmpDir, "src", "pkg", "internal") + if err := os.MkdirAll(deepDir, 0o755); err != nil { + t.Fatalf("failed to create deep dir: %v", err) + } + + roots, err := FindSpectrRoots(deepDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(roots) != 1 { + t.Fatalf("expected 1 root, got %d", len(roots)) + } + + if roots[0].Path != tmpDir { + t.Errorf("expected Path %s, got %s", tmpDir, roots[0].Path) + } + + if roots[0].RelativeTo != "../../.." { + t.Errorf("expected RelativeTo '../../..', got %s", roots[0].RelativeTo) + } +} + +func TestSpectrRoot_SpectrDir(t *testing.T) { + root := SpectrRoot{Path: "/home/user/project"} + expected := "/home/user/project/spectr" + if got := root.SpectrDir(); got != expected { + t.Errorf("expected %s, got %s", expected, got) + } +} + +func TestSpectrRoot_ChangesDir(t *testing.T) { + root := SpectrRoot{Path: "/home/user/project"} + expected := "/home/user/project/spectr/changes" + if got := root.ChangesDir(); got != expected { + t.Errorf("expected %s, got %s", expected, got) + } +} + +func TestSpectrRoot_SpecsDir(t *testing.T) { + root := SpectrRoot{Path: "/home/user/project"} + expected := "/home/user/project/spectr/specs" + if got := root.SpecsDir(); got != expected { + t.Errorf("expected %s, got %s", expected, got) + } +} + +func TestSpectrRoot_DisplayName(t *testing.T) { + tests := []struct { + name string + root SpectrRoot + expected string + }{ + { + name: "current directory", + root: SpectrRoot{ + Path: "/home/user/project", + RelativeTo: ".", + }, + expected: "project", + }, + { + name: "relative path", + root: SpectrRoot{ + Path: "/home/user/mono/project", + RelativeTo: "../project", + }, + expected: "../project", + }, + { + name: "parent relative", + root: SpectrRoot{ + Path: "/home/user", + RelativeTo: "../..", + }, + expected: "../..", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.root.DisplayName(); got != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, got) + } + }) + } +} + +// setupNestedGitRepoFixture creates a mono-repo structure with nested git repositories +// Returns the root temp directory path. +func setupNestedGitRepoFixture(t *testing.T) string { + t.Helper() + + tmpDir := t.TempDir() + + // Create main repo .git and spectr/ + mustMkdirAll(t, filepath.Join(tmpDir, ".git")) + mustMkdirAll(t, filepath.Join(tmpDir, "spectr")) + + // Create auth package with its own .git and spectr + mustMkdirAll(t, filepath.Join(tmpDir, "packages", "auth", ".git")) + mustMkdirAll(t, filepath.Join(tmpDir, "packages", "auth", "spectr")) + + // Create api package with its own .git and spectr + mustMkdirAll(t, filepath.Join(tmpDir, "packages", "api", ".git")) + mustMkdirAll(t, filepath.Join(tmpDir, "packages", "api", "spectr")) + + return tmpDir +} + +// mustMkdirAll creates a directory, failing the test if it cannot. +func mustMkdirAll(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("failed to create directory %s: %v", path, err) + } +} + +// assertSingleRoot verifies exactly one root is found with the expected path. +func assertSingleRoot(t *testing.T, roots []SpectrRoot, expectedPath string) { + t.Helper() + + if len(roots) != 1 { + t.Errorf("expected 1 root, got %d", len(roots)) + for i, r := range roots { + t.Logf(" root[%d]: %s", i, r.Path) + } + + return + } + + if roots[0].Path != expectedPath { + t.Errorf("expected root to be %s, got %s", expectedPath, roots[0].Path) + } +} + +func TestFindSpectrRoots_NestedGitRepos(t *testing.T) { + // Integration test: mono-repo with nested git repositories + // Structure: + // mono-repo/ + // .git/ <- main repo boundary + // spectr/ <- main repo spectr + // packages/ + // auth/ + // .git/ <- nested git repo + // spectr/ <- auth's spectr + // api/ + // .git/ <- nested git repo + // spectr/ <- api's spectr + tmpDir := setupNestedGitRepoFixture(t) + + t.Run("from mono-repo root finds all spectr directories", func(t *testing.T) { + roots, err := FindSpectrRoots(tmpDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should find 3 roots: main, auth, and api + if len(roots) != 3 { + t.Fatalf("expected 3 roots (main + auth + api), got %d", len(roots)) + } + + // First root should be main (closest to cwd) + if roots[0].Path != tmpDir { + t.Errorf("expected first root to be main repo, got %s", roots[0].Path) + } + + // Verify auth and api were found + foundAuth := false + foundAPI := false + for _, root := range roots[1:] { + switch filepath.Base(root.Path) { + case "auth": + foundAuth = true + case "api": + foundAPI = true + } + } + + if !foundAuth || !foundAPI { + t.Error("expected to find both auth and api subprojects") + } + }) + + t.Run("from auth package finds only auth spectr", func(t *testing.T) { + authDir := filepath.Join(tmpDir, "packages", "auth") + roots, err := FindSpectrRoots(authDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertSingleRoot(t, roots, authDir) + }) + + t.Run("from api subdirectory finds only api spectr", func(t *testing.T) { + apiSrc := filepath.Join(tmpDir, "packages", "api", "src") + mustMkdirAll(t, apiSrc) + + roots, err := FindSpectrRoots(apiSrc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + apiDir := filepath.Join(tmpDir, "packages", "api") + assertSingleRoot(t, roots, apiDir) + }) + + t.Run("from packages directory finds main spectr", func(t *testing.T) { + packagesDir := filepath.Join(tmpDir, "packages") + + roots, err := FindSpectrRoots(packagesDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // packages/ is within the main repo's git boundary + assertSingleRoot(t, roots, tmpDir) + }) +} + +func TestFindSpectrRoots_RelativePathCalculation(t *testing.T) { + // Test that relative paths are correctly calculated for multi-root scenarios + tmpDir := t.TempDir() + + // Create nested structure within same git repo: + // tmpDir/ + // .git/ + // spectr/ + // services/ + // backend/ + // spectr/ + // src/ + // handlers/ <- cwd + + gitDir := filepath.Join(tmpDir, ".git") + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatalf("failed to create .git dir: %v", err) + } + + rootSpectr := filepath.Join(tmpDir, "spectr") + if err := os.MkdirAll(rootSpectr, 0o755); err != nil { + t.Fatalf("failed to create root spectr dir: %v", err) + } + + backendSpectr := filepath.Join(tmpDir, "services", "backend", "spectr") + if err := os.MkdirAll(backendSpectr, 0o755); err != nil { + t.Fatalf("failed to create backend spectr dir: %v", err) + } + + handlersDir := filepath.Join(tmpDir, "services", "backend", "src", "handlers") + if err := os.MkdirAll(handlersDir, 0o755); err != nil { + t.Fatalf("failed to create handlers dir: %v", err) + } + + roots, err := FindSpectrRoots(handlersDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(roots) != 2 { + t.Fatalf("expected 2 roots, got %d", len(roots)) + } + + // First root should be backend (closest) + backendDir := filepath.Join(tmpDir, "services", "backend") + if roots[0].Path != backendDir { + t.Errorf("expected first root Path %s, got %s", backendDir, roots[0].Path) + } + // nolint:goconst // Test-specific relative path, not worth extracting as constant + if roots[0].RelativeTo != "../.." { + t.Errorf("expected first root RelativeTo '../..', got %s", roots[0].RelativeTo) + } + + // Second root should be the root tmpDir + if roots[1].Path != tmpDir { + t.Errorf("expected second root Path %s, got %s", tmpDir, roots[1].Path) + } + if roots[1].RelativeTo != "../../../.." { + t.Errorf("expected second root RelativeTo '../../../..', got %s", roots[1].RelativeTo) + } +} + +// TestFindSpectrRootsDownward tests the downward directory discovery. +// nolint:revive // Complex test function with multiple comprehensive scenarios +func TestFindSpectrRootsDownward(t *testing.T) { + t.Run("finds nested spectr directories with git", func(t *testing.T) { + // Create structure: + // tmpDir/ + // project1/ + // .git/ + // spectr/ + // project2/ + // .git/ + // spectr/ + // project3/ + // spectr/ (no .git - should NOT be found) + tmpDir := t.TempDir() + + mustMkdirAll(t, filepath.Join(tmpDir, "project1", ".git")) + mustMkdirAll(t, filepath.Join(tmpDir, "project1", "spectr")) + + mustMkdirAll(t, filepath.Join(tmpDir, "project2", ".git")) + mustMkdirAll(t, filepath.Join(tmpDir, "project2", "spectr")) + + mustMkdirAll(t, filepath.Join(tmpDir, "project3", "spectr")) + + roots, err := findSpectrRootsDownward(tmpDir, tmpDir, maxDiscoveryDepth) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should only find 2 roots (project3 has no .git) + if len(roots) != 2 { + t.Fatalf("expected 2 roots (project1 and project2 with .git), got %d", len(roots)) + } + + // Verify only projects with .git were found + foundProjects := make(map[string]bool) + for _, root := range roots { + baseName := filepath.Base(root.Path) + foundProjects[baseName] = true + + // Verify git root is set correctly + if root.GitRoot != root.Path { + t.Errorf("expected GitRoot %s for %s, got %s", + root.Path, baseName, root.GitRoot) + } + } + + expectedProjects := []string{"project1", "project2"} + for _, project := range expectedProjects { + if !foundProjects[project] { + t.Errorf("expected to find %s", project) + } + } + + // project3 should NOT be found (no .git) + if foundProjects["project3"] { + t.Error("project3 should NOT be found (no .git at same level)") + } + }) + + t.Run("respects max depth limit", func(t *testing.T) { + // Create deep nesting: + // tmpDir/ + // a/b/c/d/e/f/g/h/i/j/k/ + // spectr/ <- at depth 11, should not be found with maxDepth=10 + tmpDir := t.TempDir() + deepPath := filepath.Join(tmpDir, "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k") + mustMkdirAll(t, filepath.Join(deepPath, "spectr")) + + roots, err := findSpectrRootsDownward(tmpDir, tmpDir, 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(roots) != 0 { + t.Errorf("expected 0 roots (too deep), got %d", len(roots)) + } + }) + + t.Run("skips ignored directories", func(t *testing.T) { + // Create structure with directories that should be skipped: + // tmpDir/ + // .git/spectr/ <- should skip (ignored dir) + // node_modules/spectr/ <- should skip (ignored dir) + // vendor/spectr/ <- should skip (ignored dir) + // project/ + // .git/ + // spectr/ <- should find (has .git) + tmpDir := t.TempDir() + + mustMkdirAll(t, filepath.Join(tmpDir, ".git", "spectr")) + mustMkdirAll(t, filepath.Join(tmpDir, "node_modules", "spectr")) + mustMkdirAll(t, filepath.Join(tmpDir, "vendor", "spectr")) + mustMkdirAll(t, filepath.Join(tmpDir, "project", ".git")) + mustMkdirAll(t, filepath.Join(tmpDir, "project", "spectr")) + + roots, err := findSpectrRootsDownward(tmpDir, tmpDir, maxDiscoveryDepth) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(roots) != 1 { + t.Fatalf("expected 1 root (only project), got %d", len(roots)) + } + + if filepath.Base(roots[0].Path) != "project" { + t.Errorf("expected to find project, got %s", roots[0].Path) + } + }) + + t.Run("finds nested repos in subdirectories", func(t *testing.T) { + // Create mono-repo structure: + // tmpDir/ + // packages/ + // auth/ + // .git/ + // spectr/ + // api/ + // .git/ + // spectr/ + tmpDir := t.TempDir() + + mustMkdirAll(t, filepath.Join(tmpDir, "packages", "auth", ".git")) + mustMkdirAll(t, filepath.Join(tmpDir, "packages", "auth", "spectr")) + + mustMkdirAll(t, filepath.Join(tmpDir, "packages", "api", ".git")) + mustMkdirAll(t, filepath.Join(tmpDir, "packages", "api", "spectr")) + + roots, err := findSpectrRootsDownward(tmpDir, tmpDir, maxDiscoveryDepth) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(roots) != 2 { + t.Fatalf("expected 2 roots, got %d", len(roots)) + } + + // Verify both auth and api were found + foundRepos := make(map[string]bool) + for _, root := range roots { + baseName := filepath.Base(root.Path) + foundRepos[baseName] = true + + // Both should have .git, so GitRoot should be set + if root.GitRoot == "" { + t.Errorf("expected GitRoot to be set for %s", baseName) + } + } + + if !foundRepos["auth"] || !foundRepos["api"] { + t.Error("expected to find both auth and api repos") + } + }) + + t.Run("continues after finding spectr", func(t *testing.T) { + // Verify it doesn't stop at first spectr/ found + // (both projects are siblings, not nested) + // tmpDir/ + // projectA/ + // .git/ + // spectr/ + // projectB/ + // .git/ + // spectr/ + tmpDir := t.TempDir() + + mustMkdirAll(t, filepath.Join(tmpDir, "projectA", ".git")) + mustMkdirAll(t, filepath.Join(tmpDir, "projectA", "spectr")) + mustMkdirAll(t, filepath.Join(tmpDir, "projectB", ".git")) + mustMkdirAll(t, filepath.Join(tmpDir, "projectB", "spectr")) + + roots, err := findSpectrRootsDownward(tmpDir, tmpDir, maxDiscoveryDepth) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(roots) != 2 { + t.Fatalf("expected 2 roots, got %d", len(roots)) + } + }) + + t.Run("handles permission errors gracefully", func(t *testing.T) { + // This test verifies the function continues when it encounters + // directories it can't read (permission errors) + tmpDir := t.TempDir() + + // Create a readable project with .git + mustMkdirAll(t, filepath.Join(tmpDir, "readable", ".git")) + mustMkdirAll(t, filepath.Join(tmpDir, "readable", "spectr")) + + // Note: We can't reliably test permission errors in all environments + // (CI, different OSes, etc.), so we just verify the function doesn't + // crash and finds the readable directory + roots, err := findSpectrRootsDownward(tmpDir, tmpDir, maxDiscoveryDepth) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(roots) < 1 { + t.Error("expected to find at least the readable directory") + } + }) + + t.Run("calculates relative paths correctly", func(t *testing.T) { + // Test from different cwd + tmpDir := t.TempDir() + + mustMkdirAll(t, filepath.Join(tmpDir, "projects", "myapp", ".git")) + mustMkdirAll(t, filepath.Join(tmpDir, "projects", "myapp", "spectr")) + + // Pretend cwd is somewhere else + fakeCwd := filepath.Join(tmpDir, "somedir") + mustMkdirAll(t, fakeCwd) + + roots, err := findSpectrRootsDownward(tmpDir, fakeCwd, maxDiscoveryDepth) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(roots) != 1 { + t.Fatalf("expected 1 root, got %d", len(roots)) + } + + // RelativeTo should be relative to fakeCwd + expectedRelPath := "../projects/myapp" + if roots[0].RelativeTo != expectedRelPath { + t.Errorf("expected RelativeTo %s, got %s", + expectedRelPath, roots[0].RelativeTo) + } + }) +} + +// TestDeduplicateRoots tests the deduplicateRoots helper function. +func TestDeduplicateRoots(t *testing.T) { + t.Run("empty slice", func(t *testing.T) { + var roots []SpectrRoot + result := deduplicateRoots(roots) + + if len(result) != 0 { + t.Errorf("expected empty slice, got %d roots", len(result)) + } + }) + + t.Run("no duplicates", func(t *testing.T) { + roots := []SpectrRoot{ + {Path: "/home/user/project1", RelativeTo: ".", GitRoot: "/home/user/project1"}, + { + Path: "/home/user/project2", + RelativeTo: "../project2", + GitRoot: "/home/user/project2", + }, + { + Path: "/home/user/project3", + RelativeTo: "../project3", + GitRoot: "/home/user/project3", + }, + } + + result := deduplicateRoots(roots) + + if len(result) != 3 { + t.Errorf("expected 3 roots, got %d", len(result)) + } + + for i, root := range result { + if root.Path != roots[i].Path { + t.Errorf("order changed: expected %s at index %d, got %s", + roots[i].Path, i, root.Path) + } + } + }) + + t.Run("with duplicates preserves first occurrence", func(t *testing.T) { + roots := []SpectrRoot{ + {Path: "/home/user/project1", RelativeTo: ".", GitRoot: "/home/user/project1"}, + { + Path: "/home/user/project2", + RelativeTo: "../project2", + GitRoot: "/home/user/project2", + }, + { + Path: "/home/user/project1", + RelativeTo: "different", + GitRoot: "different", + }, // duplicate + { + Path: "/home/user/project3", + RelativeTo: "../project3", + GitRoot: "/home/user/project3", + }, + { + Path: "/home/user/project2", + RelativeTo: "also-different", + GitRoot: "also-different", + }, // duplicate + } + + result := deduplicateRoots(roots) + + if len(result) != 3 { + t.Errorf("expected 3 unique roots, got %d", len(result)) + } + + // Verify first occurrences were preserved + if result[0].Path != "/home/user/project1" || result[0].RelativeTo != "." { + t.Error("first occurrence of project1 not preserved") + } + + if result[1].Path != "/home/user/project2" || result[1].RelativeTo != "../project2" { + t.Error("first occurrence of project2 not preserved") + } + + if result[2].Path != "/home/user/project3" { + t.Error("project3 not in result") + } + }) + + t.Run("all duplicates", func(t *testing.T) { + roots := []SpectrRoot{ + {Path: "/home/user/project", RelativeTo: ".", GitRoot: "/home/user/project"}, + {Path: "/home/user/project", RelativeTo: "different1", GitRoot: "git1"}, + {Path: "/home/user/project", RelativeTo: "different2", GitRoot: "git2"}, + } + + result := deduplicateRoots(roots) + + if len(result) != 1 { + t.Errorf("expected 1 unique root, got %d", len(result)) + } + + if result[0].RelativeTo != "." { + t.Error("first occurrence not preserved") + } + }) +} + +// TestSortRootsByDistance tests the sortRootsByDistance helper function. +func TestSortRootsByDistance(t *testing.T) { + t.Run("empty slice", func(t *testing.T) { + var roots []SpectrRoot + cwd := "/home/user/project" + + result := sortRootsByDistance(roots, cwd) + + if len(result) != 0 { + t.Errorf("expected empty slice, got %d roots", len(result)) + } + }) + + t.Run("single root", func(t *testing.T) { + roots := []SpectrRoot{ + {Path: "/home/user/project", RelativeTo: ".", GitRoot: "/home/user/project"}, + } + cwd := "/home/user/project" + + result := sortRootsByDistance(roots, cwd) + + if len(result) != 1 { + t.Errorf("expected 1 root, got %d", len(result)) + } + + if result[0].Path != roots[0].Path { + t.Error("single root was modified") + } + }) + + t.Run("sorts by distance - closest first", func(t *testing.T) { + // Setup: cwd is at /home/user/mono/project/src + cwd := "/home/user/mono/project/src" + + roots := []SpectrRoot{ + { + Path: "/home/user/mono", + RelativeTo: "../..", + GitRoot: "/home/user/mono", + }, // distance 2 (../..) + { + Path: "/home/user/mono/project", + RelativeTo: "..", + GitRoot: "/home/user/mono", + }, // distance 1 (..) + { + Path: "/home/user/mono/project/src", + RelativeTo: ".", + GitRoot: "/home/user/mono", + }, // distance 0 (.) + { + Path: "/home/user/mono/other", + RelativeTo: "../../other", + GitRoot: "/home/user/mono", + }, // distance 2 + { + Path: "/home/user/mono/project/lib", + RelativeTo: "../lib", + GitRoot: "/home/user/mono", + }, // distance 1 + } + + result := sortRootsByDistance(roots, cwd) + + if len(result) != 5 { + t.Fatalf("expected 5 roots, got %d", len(result)) + } + + // Check order: closest (.) should be first + if result[0].Path != "/home/user/mono/project/src" { + t.Errorf("expected closest root first, got %s", result[0].Path) + } + + // Next should be distance 1 roots (.. and ../lib) + // They should be sorted alphabetically among themselves + if result[1].RelativeTo != ".." && result[1].RelativeTo != "../lib" { + t.Errorf("expected distance-1 root at index 1, got %s", result[1].RelativeTo) + } + + // Last should be distance 2 roots (../.. and ../../other) + if result[3].RelativeTo != "../.." && result[3].RelativeTo != "../../other" { + t.Errorf("expected distance-2 root at index 3, got %s", result[3].RelativeTo) + } + }) + + t.Run("preserves original slice - creates copy", func(t *testing.T) { + cwd := "/home/user/mono/project" + roots := []SpectrRoot{ + {Path: "/home/user/mono", RelativeTo: "..", GitRoot: "/home/user/mono"}, + {Path: "/home/user/mono/project", RelativeTo: ".", GitRoot: "/home/user/mono"}, + } + + originalFirstPath := roots[0].Path + + result := sortRootsByDistance(roots, cwd) + + // Original slice should be unchanged + if roots[0].Path != originalFirstPath { + t.Error("original slice was modified") + } + + // Result should be sorted differently + if result[0].Path != originalFirstPath { + return // Different, as expected + } + + // This might happen if they were already sorted, check second element + if len(result) > 1 && result[1].Path == roots[1].Path { + t.Error("result appears to be the same as input (should be sorted)") + } + }) + + t.Run("handles equal distances alphabetically", func(t *testing.T) { + cwd := "/home/user/mono" + + roots := []SpectrRoot{ + {Path: "/home/user/mono/zeta", RelativeTo: "zeta", GitRoot: "/home/user/mono"}, + {Path: "/home/user/mono/alpha", RelativeTo: "alpha", GitRoot: "/home/user/mono"}, + {Path: "/home/user/mono/beta", RelativeTo: "beta", GitRoot: "/home/user/mono"}, + } + + result := sortRootsByDistance(roots, cwd) + + if len(result) != 3 { + t.Fatalf("expected 3 roots, got %d", len(result)) + } + + // All at same distance, should be alphabetically sorted + if result[0].RelativeTo != "alpha" { + t.Errorf("expected 'alpha' first, got %s", result[0].RelativeTo) + } + + if result[1].RelativeTo != "beta" { + t.Errorf("expected 'beta' second, got %s", result[1].RelativeTo) + } + + if result[2].RelativeTo != "zeta" { + t.Errorf("expected 'zeta' third, got %s", result[2].RelativeTo) + } + }) +} + +// TestFindSpectrRoots_SubdirectoryDiscovery tests basic subdirectory discovery. +// This verifies that FindSpectrRoots can find spectr/ directories in subdirectories +// when running from a directory without a git repository. +// Only directories with .git at the same level as spectr/ are discovered. +func TestFindSpectrRoots_SubdirectoryDiscovery(t *testing.T) { + // Create structure (no git boundary at root, so downward discovery should happen): + // tmpDir/ + // project1/ + // .git/ + // spectr/ + // project2/ + // .git/ + // spectr/ + // deeply/ + // nested/ + // project3/ + // .git/ + // spectr/ + tmpDir := t.TempDir() + + mustMkdirAll(t, filepath.Join(tmpDir, "project1", ".git")) + mustMkdirAll(t, filepath.Join(tmpDir, "project1", "spectr")) + mustMkdirAll(t, filepath.Join(tmpDir, "project2", ".git")) + mustMkdirAll(t, filepath.Join(tmpDir, "project2", "spectr")) + mustMkdirAll(t, filepath.Join(tmpDir, "deeply", "nested", "project3", ".git")) + mustMkdirAll(t, filepath.Join(tmpDir, "deeply", "nested", "project3", "spectr")) + + // Run from tmpDir (no .git at root, so downward discovery occurs) + roots, err := FindSpectrRoots(tmpDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(roots) != 3 { + for i, r := range roots { + t.Logf(" root[%d]: %s", i, r.Path) + } + t.Fatalf("expected 3 roots, got %d", len(roots)) + } + + // Verify all projects were found + foundProjects := make(map[string]bool) + for _, root := range roots { + baseName := filepath.Base(root.Path) + foundProjects[baseName] = true + + // Verify GitRoot is set (each project has its own .git) + if root.GitRoot == "" { + t.Errorf("expected GitRoot to be set for %s", baseName) + } + } + + expectedProjects := []string{"project1", "project2", "project3"} + for _, project := range expectedProjects { + if !foundProjects[project] { + t.Errorf("expected to find %s", project) + } + } +} + +// TestFindSpectrRoots_MonorepoWithSubprojects tests the exact GitHub issue #363 scenario: +// a mono-repo with nested git repositories, each with their own spectr/ directory. +func TestFindSpectrRoots_MonorepoWithSubprojects(t *testing.T) { + // Create the exact structure from GitHub issue #363: + // mono-repo/ + // .git/ <- main repo boundary + // spectr/ <- main repo spectr + // packages/ + // auth/ + // .git/ <- nested git repo + // spectr/ <- auth's spectr + // src/ + // lib/ <- test from here + // api/ + // .git/ <- nested git repo + // spectr/ <- api's spectr + tmpDir := t.TempDir() + + // Create main mono-repo with .git and spectr/ + mustMkdirAll(t, filepath.Join(tmpDir, ".git")) + mustMkdirAll(t, filepath.Join(tmpDir, "spectr")) + + // Create auth package with its own .git and spectr/ + authDir := filepath.Join(tmpDir, "packages", "auth") + mustMkdirAll(t, filepath.Join(authDir, ".git")) + mustMkdirAll(t, filepath.Join(authDir, "spectr")) + authSrcLib := filepath.Join(authDir, "src", "lib") + mustMkdirAll(t, authSrcLib) + + // Create api package with its own .git and spectr/ + apiDir := filepath.Join(tmpDir, "packages", "api") + mustMkdirAll(t, filepath.Join(apiDir, ".git")) + mustMkdirAll(t, filepath.Join(apiDir, "spectr")) + + t.Run("from mono-repo root finds all spectr directories", func(t *testing.T) { + roots, err := FindSpectrRoots(tmpDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should find 3 roots: main, auth, and api + if len(roots) != 3 { + t.Fatalf("expected 3 roots (main + auth + api), got %d", len(roots)) + } + + // First root should be main (closest to cwd) + if roots[0].Path != tmpDir { + t.Errorf("expected first root to be main repo, got %s", roots[0].Path) + } + if roots[0].GitRoot != tmpDir { + t.Errorf("expected main GitRoot %s, got %s", tmpDir, roots[0].GitRoot) + } + + // Verify auth and api were found (order may vary due to alphabetical sorting) + foundAuth := false + foundAPI := false + for _, root := range roots[1:] { + switch filepath.Base(root.Path) { + case "auth": + foundAuth = true + if root.GitRoot != authDir { + t.Errorf("expected auth GitRoot %s, got %s", authDir, root.GitRoot) + } + case "api": + foundAPI = true + if root.GitRoot != apiDir { + t.Errorf("expected api GitRoot %s, got %s", apiDir, root.GitRoot) + } + } + } + + if !foundAuth { + t.Error("expected to find auth subproject") + } + if !foundAPI { + t.Error("expected to find api subproject") + } + }) + + t.Run("from auth/src/lib finds only auth spectr (upward to git boundary)", func(t *testing.T) { + roots, err := FindSpectrRoots(authSrcLib) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertSingleRoot(t, roots, authDir) + if roots[0].GitRoot != authDir { + t.Errorf("expected GitRoot %s, got %s", authDir, roots[0].GitRoot) + } + // Verify RelativeTo is correct + expectedRel := "../.." + if roots[0].RelativeTo != expectedRel { + t.Errorf("expected RelativeTo %s, got %s", expectedRel, roots[0].RelativeTo) + } + }) + + t.Run("from api package finds only api spectr", func(t *testing.T) { + roots, err := FindSpectrRoots(apiDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertSingleRoot(t, roots, apiDir) + if roots[0].GitRoot != apiDir { + t.Errorf("expected GitRoot %s, got %s", apiDir, roots[0].GitRoot) + } + }) + + t.Run("from packages directory finds only main spectr", func(t *testing.T) { + packagesDir := filepath.Join(tmpDir, "packages") + roots, err := FindSpectrRoots(packagesDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // packages/ is within the main repo's git boundary + assertSingleRoot(t, roots, tmpDir) + if roots[0].GitRoot != tmpDir { + t.Errorf("expected GitRoot %s, got %s", tmpDir, roots[0].GitRoot) + } + }) +} + +// TestFindSpectrRoots_DepthLimit verifies that the 10-level depth limit is enforced +// during downward discovery, preventing excessive directory traversal. +func TestFindSpectrRoots_DepthLimit(t *testing.T) { + // Note: This test directly tests findSpectrRootsDownward since FindSpectrRoots + // only does downward discovery when NOT in a git repo. Testing the internal + // function ensures the depth limit logic is verified. + // + // Due to how WalkDir and calculateDepth work together, the effective depths are: + // - tmpDir (start path): depth 0 in depthMap initialization, but calculated as depth 1 + // - tmpDir/a: depth 2 + // - tmpDir/a/b/c/d/e/f/g/h/i: depth 10 (at limit) + // - tmpDir/a/b/c/d/e/f/g/h/i/j: depth 11 (exceeds limit) + tmpDir := t.TempDir() + + // Create shallow spectr with .git (depth 2 with current implementation) + mustMkdirAll(t, filepath.Join(tmpDir, "shallow", ".git")) + mustMkdirAll(t, filepath.Join(tmpDir, "shallow", "spectr")) + + // Create at-limit spectr with .git (depth 10) + // Using 9 path segments: a/b/c/d/e/f/g/h/i + atLimitPath := filepath.Join(tmpDir, "a", "b", "c", "d", "e", "f", "g", "h", "i") + mustMkdirAll(t, filepath.Join(atLimitPath, ".git")) + mustMkdirAll(t, filepath.Join(atLimitPath, "spectr")) + + // Create too-deep spectr with .git (depth 11, should not be found due to depth limit) + // Using 10 path segments: a/b/c/d/e/f/g/h/i/j + tooDeepPath := filepath.Join(tmpDir, "a", "b", "c", "d", "e", "f", "g", "h", "i", "j") + mustMkdirAll(t, filepath.Join(tooDeepPath, ".git")) + mustMkdirAll(t, filepath.Join(tooDeepPath, "spectr")) + + // Test the internal downward discovery function directly + roots, err := findSpectrRootsDownward(tmpDir, tmpDir, maxDiscoveryDepth) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should find 2 roots: shallow and at-limit, but NOT too-deep + if len(roots) != 2 { + for i, r := range roots { + relPath, _ := filepath.Rel(tmpDir, r.Path) + t.Logf(" root[%d]: %s", i, relPath) + } + t.Fatalf("expected 2 roots (shallow and at-limit), got %d", len(roots)) + } + + // Verify shallow was found + foundShallow := false + foundAtLimit := false + foundTooDeep := false + + for _, root := range roots { + if filepath.Base(root.Path) == "shallow" { + foundShallow = true + } + if root.Path == atLimitPath { + foundAtLimit = true + } + if root.Path == tooDeepPath { + foundTooDeep = true + } + } + + if !foundShallow { + t.Error("expected to find shallow spectr/") + } + if !foundAtLimit { + t.Error("expected to find spectr/ at depth limit") + } + if foundTooDeep { + t.Error("should NOT find spectr/ beyond depth limit") + } +} + +// TestFindSpectrRoots_SkipsIgnoredDirs verifies that discovery skips common +// directories that should not contain spectr/ directories (.git, node_modules, etc). +func TestFindSpectrRoots_SkipsIgnoredDirs(t *testing.T) { + // Note: This test directly tests findSpectrRootsDownward since FindSpectrRoots + // only does downward discovery when NOT in a git repo. Testing the internal + // function ensures the skip logic is verified. + tmpDir := t.TempDir() + + // Create spectr/ inside ignored directories (these should be skipped) + mustMkdirAll(t, filepath.Join(tmpDir, ".git", "spectr")) + mustMkdirAll(t, filepath.Join(tmpDir, "node_modules", "package", "spectr")) + mustMkdirAll(t, filepath.Join(tmpDir, "vendor", "lib", "spectr")) + mustMkdirAll(t, filepath.Join(tmpDir, "target", "spectr")) + mustMkdirAll(t, filepath.Join(tmpDir, "dist", "spectr")) + mustMkdirAll(t, filepath.Join(tmpDir, "build", "spectr")) + + // Create valid spectr/ in regular project directory with .git + mustMkdirAll(t, filepath.Join(tmpDir, "project", ".git")) + mustMkdirAll(t, filepath.Join(tmpDir, "project", "spectr")) + + // Test the internal downward discovery function directly + roots, err := findSpectrRootsDownward(tmpDir, tmpDir, maxDiscoveryDepth) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should find only 1 root: project (all others should be skipped) + if len(roots) != 1 { + for i, r := range roots { + t.Logf(" root[%d]: %s", i, r.Path) + } + t.Fatalf("expected 1 root (only project), got %d", len(roots)) + } + + if filepath.Base(roots[0].Path) != "project" { + t.Errorf("expected to find only project, got %s", roots[0].Path) + } +} + +// TestFindSpectrRoots_Deduplication verifies that when both upward and downward +// discovery find the same spectr/ directories, duplicates are properly removed. +func TestFindSpectrRoots_Deduplication(t *testing.T) { + // Create structure where upward and downward discovery might find duplicates: + // tmpDir/ + // .git/ + // spectr/ <- found by both upward (from cwd) and would be found by downward + // subproject/ + // spectr/ <- found by downward discovery + // subdir/ <- cwd (starting point) + tmpDir := t.TempDir() + + // Create .git at root + mustMkdirAll(t, filepath.Join(tmpDir, ".git")) + + // Create main spectr/ + mustMkdirAll(t, filepath.Join(tmpDir, "spectr")) + + // Create subproject with spectr/ + subprojectDir := filepath.Join(tmpDir, "subproject") + mustMkdirAll(t, filepath.Join(subprojectDir, "spectr")) + + // Create subdirectory as cwd + subdirPath := filepath.Join(subprojectDir, "subdir") + mustMkdirAll(t, subdirPath) + + // Run discovery from subdir + roots, err := FindSpectrRoots(subdirPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should find 2 unique roots: subproject (upward, closest) and tmpDir (upward) + // No duplicates should exist + if len(roots) != 2 { + for i, r := range roots { + t.Logf(" root[%d]: %s", i, r.Path) + } + t.Fatalf("expected 2 roots (subproject and tmpDir), got %d", len(roots)) + } + + // Verify no duplicates by checking each path appears only once + seen := make(map[string]int) + for _, root := range roots { + seen[root.Path]++ + } + + for path, count := range seen { + if count > 1 { + t.Errorf("duplicate path found %d times: %s", count, path) + } + } + + // Verify correct roots were found (order matters: closest first) + if roots[0].Path != subprojectDir { + t.Errorf("expected first root to be subproject (closest), got %s", roots[0].Path) + } + if roots[1].Path != tmpDir { + t.Errorf("expected second root to be tmpDir, got %s", roots[1].Path) + } +} + +// TestFindSpectrRoots_RequiresGitAtSameLevel verifies that spectr/ directories +// without .git at the same level are NOT discovered during downward discovery. +// This prevents test fixtures like examples/*/spectr/ from being picked up. +func TestFindSpectrRoots_RequiresGitAtSameLevel(t *testing.T) { + tmpDir := t.TempDir() + + // Create .git and spectr at root + mustMkdirAll(t, filepath.Join(tmpDir, ".git")) + mustMkdirAll(t, filepath.Join(tmpDir, "spectr")) + + // Create examples subdirectory with spectr but NO .git (like test fixtures) + mustMkdirAll(t, filepath.Join(tmpDir, "examples", "test", "spectr")) + mustMkdirAll(t, filepath.Join(tmpDir, "examples", "list", "spectr")) + + roots, err := FindSpectrRoots(tmpDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should only find root, not examples/* + if len(roots) != 1 { + for i, r := range roots { + t.Logf(" root[%d]: %s", i, r.Path) + } + t.Fatalf("expected 1 root (examples/* have no .git), got %d", len(roots)) + } + + if roots[0].Path != tmpDir { + t.Errorf("expected root at %s, got %s", tmpDir, roots[0].Path) + } +} + +// Helper function +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || s != "" && containsAt(s, substr, 0)) +} + +func containsAt(s, substr string, start int) bool { + for i := start; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + + return false +} diff --git a/internal/list/formatters.go b/internal/list/formatters.go index 8d41d62a..457fc729 100644 --- a/internal/list/formatters.go +++ b/internal/list/formatters.go @@ -13,8 +13,47 @@ const ( // Common messages noItemsFoundMsg = "No items found" lineSeparator = "\n" + + // Path constants + currentDirPath = "." ) +// FormatMode represents the display mode for multi-root formatting. +type FormatMode int + +const ( + // FormatModeSingle indicates single-root mode (no prefix). + FormatModeSingle FormatMode = iota + // FormatModeMulti indicates multi-root mode (show root prefix). + FormatModeMulti +) + +// NewFormatMode creates a FormatMode based on whether there are multiple roots. +// +//nolint:revive // flag-parameter: hasMultipleRoots intentionally controls mode selection +func NewFormatMode(hasMultipleRoots bool) FormatMode { + if hasMultipleRoots { + return FormatModeMulti + } + + return FormatModeSingle +} + +// IsMulti returns true if this is multi-root mode. +func (m FormatMode) IsMulti() bool { + return m == FormatModeMulti +} + +// FormatItemWithRoot formats an item ID with a root prefix when in multi-root mode. +// For single-root or when rootPath is "." or empty, returns just the ID. +func FormatItemWithRoot(rootPath, id string, mode FormatMode) string { + if rootPath == currentDirPath || rootPath == "" || !mode.IsMulti() { + return id + } + + return fmt.Sprintf("[%s] %s", rootPath, id) +} + // FormatChangesText formats changes as simple text list (IDs only) func FormatChangesText( changes []ChangeInfo, @@ -178,3 +217,167 @@ func FormatSpecsJSON( return string(data), nil } + +// Multi-root formatting functions + +// FormatChangesTextMulti formats changes with optional root prefix for multi-root scenarios. +func FormatChangesTextMulti( + changes []ChangeInfo, + mode FormatMode, +) string { + if len(changes) == 0 { + return noItemsFoundMsg + } + + // Sort by ID + sort.Slice(changes, func(i, j int) bool { + return changes[i].ID < changes[j].ID + }) + + // Find the longest ID and root for alignment + maxIDLen := 0 + maxRootLen := 0 + for _, change := range changes { + if len(change.ID) > maxIDLen { + maxIDLen = len(change.ID) + } + if len(change.RootPath) > maxRootLen && mode.IsMulti() { + maxRootLen = len(change.RootPath) + } + } + + lines := make([]string, 0, len(changes)) + for _, change := range changes { + var line string + if change.RootPath != currentDirPath && change.RootPath != "" && mode.IsMulti() { + line = fmt.Sprintf("[%-*s] %-*s %d/%d tasks", + maxRootLen, + change.RootPath, + maxIDLen, + change.ID, + change.TaskStatus.Completed, + change.TaskStatus.Total, + ) + } else { + line = fmt.Sprintf("%-*s %d/%d tasks", + maxIDLen, + change.ID, + change.TaskStatus.Completed, + change.TaskStatus.Total, + ) + } + lines = append(lines, line) + } + + return strings.Join(lines, lineSeparator) +} + +// FormatChangesLongMulti formats changes with detailed information and optional root prefix. +func FormatChangesLongMulti( + changes []ChangeInfo, + mode FormatMode, +) string { + if len(changes) == 0 { + return noItemsFoundMsg + } + + // Sort by ID + sort.Slice(changes, func(i, j int) bool { + return changes[i].ID < changes[j].ID + }) + + lines := make([]string, 0, len(changes)) + for _, change := range changes { + var line string + if change.RootPath != currentDirPath && change.RootPath != "" && mode.IsMulti() { + line = fmt.Sprintf( + "[%s] %s: %s [deltas %d] [tasks %d/%d]", + change.RootPath, + change.ID, + change.Title, + change.DeltaCount, + change.TaskStatus.Completed, + change.TaskStatus.Total, + ) + } else { + line = fmt.Sprintf( + "%s: %s [deltas %d] [tasks %d/%d]", + change.ID, + change.Title, + change.DeltaCount, + change.TaskStatus.Completed, + change.TaskStatus.Total, + ) + } + lines = append(lines, line) + } + + return strings.Join(lines, lineSeparator) +} + +// FormatSpecsTextMulti formats specs with optional root prefix for multi-root scenarios. +func FormatSpecsTextMulti( + specs []SpecInfo, + mode FormatMode, +) string { + if len(specs) == 0 { + return noItemsFoundMsg + } + + // Sort by ID + sort.Slice(specs, func(i, j int) bool { + return specs[i].ID < specs[j].ID + }) + + lines := make([]string, 0, len(specs)) + for _, spec := range specs { + var line string + if spec.RootPath != currentDirPath && spec.RootPath != "" && mode.IsMulti() { + line = fmt.Sprintf("[%s] %s", spec.RootPath, spec.ID) + } else { + line = spec.ID + } + lines = append(lines, line) + } + + return strings.Join(lines, lineSeparator) +} + +// FormatSpecsLongMulti formats specs with detailed information and optional root prefix. +func FormatSpecsLongMulti( + specs []SpecInfo, + mode FormatMode, +) string { + if len(specs) == 0 { + return noItemsFoundMsg + } + + // Sort by ID + sort.Slice(specs, func(i, j int) bool { + return specs[i].ID < specs[j].ID + }) + + lines := make([]string, 0, len(specs)) + for _, spec := range specs { + var line string + if spec.RootPath != currentDirPath && spec.RootPath != "" && mode.IsMulti() { + line = fmt.Sprintf( + "[%s] %s: %s [requirements %d]", + spec.RootPath, + spec.ID, + spec.Title, + spec.RequirementCount, + ) + } else { + line = fmt.Sprintf( + "%s: %s [requirements %d]", + spec.ID, + spec.Title, + spec.RequirementCount, + ) + } + lines = append(lines, line) + } + + return strings.Join(lines, lineSeparator) +} diff --git a/internal/list/formatters_test.go b/internal/list/formatters_test.go index 1dd5c7da..08699dc5 100644 --- a/internal/list/formatters_test.go +++ b/internal/list/formatters_test.go @@ -428,7 +428,7 @@ func TestFormatAllText(t *testing.T) { { name: "Mixed items sorted", items: ItemList{ - NewChangeItem(ChangeInfo{ + NewChangeItem(&ChangeInfo{ ID: "update-docs", Title: "Update Docs", DeltaCount: 1, @@ -442,7 +442,7 @@ func TestFormatAllText(t *testing.T) { Title: "API", RequirementCount: 12, }), - NewChangeItem(ChangeInfo{ + NewChangeItem(&ChangeInfo{ ID: testAddFeature, Title: "Add Feature", DeltaCount: 2, @@ -488,7 +488,7 @@ func TestFormatAllText(t *testing.T) { func TestFormatAllLong(t *testing.T) { items := ItemList{ - NewChangeItem(ChangeInfo{ + NewChangeItem(&ChangeInfo{ ID: testAddFeature, Title: "Add Feature", DeltaCount: 2, @@ -563,7 +563,7 @@ func TestFormatAllLong(t *testing.T) { func TestFormatAllJSON(t *testing.T) { items := ItemList{ - NewChangeItem(ChangeInfo{ + NewChangeItem(&ChangeInfo{ ID: "update-docs", Title: "Update Docs", DeltaCount: 1, @@ -624,3 +624,139 @@ func TestFormatAllJSON_Empty(t *testing.T) { t.Errorf("Expected '[]', got %q", result) } } + +// Multi-root formatting tests + +func TestFormatItemWithRoot(t *testing.T) { + tests := []struct { + name string + rootPath string + id string + mode FormatMode + expected string + }{ + { + name: "single root returns just ID", + rootPath: ".", + id: "add-feature", + mode: FormatModeSingle, + expected: "add-feature", + }, + { + name: "multiple roots with dot returns just ID", + rootPath: ".", + id: "add-feature", + mode: FormatModeMulti, + expected: "add-feature", + }, + { + name: "multiple roots with empty returns just ID", + rootPath: "", + id: "add-feature", + mode: FormatModeMulti, + expected: "add-feature", + }, + { + name: "multiple roots with path returns prefixed ID", + rootPath: "../project", + id: "add-feature", + mode: FormatModeMulti, + expected: "[../project] add-feature", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatItemWithRoot(tt.rootPath, tt.id, tt.mode) + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestFormatChangesTextMulti_SingleRoot(t *testing.T) { + changes := []ChangeInfo{ + { + ID: "add-feature", + Title: "Add Feature", + DeltaCount: 2, + TaskStatus: parsers.TaskStatus{Total: 5, Completed: 3}, + RootPath: ".", + }, + } + + result := FormatChangesTextMulti(changes, FormatModeSingle) + + // Should NOT have root prefix for single root + if strings.Contains(result, "[") { + t.Error("Single root should not have prefix") + } + if !strings.Contains(result, "add-feature") { + t.Error("Should contain change ID") + } +} + +func TestFormatChangesTextMulti_MultipleRoots(t *testing.T) { + changes := []ChangeInfo{ + { + ID: "add-feature", + Title: "Add Feature", + DeltaCount: 2, + TaskStatus: parsers.TaskStatus{Total: 5, Completed: 3}, + RootPath: "../project", + }, + { + ID: "fix-bug", + Title: "Fix Bug", + DeltaCount: 1, + TaskStatus: parsers.TaskStatus{Total: 3, Completed: 1}, + RootPath: ".", + }, + } + + result := FormatChangesTextMulti(changes, FormatModeMulti) + + // First change should have root prefix + if !strings.Contains(result, "[../project]") { + t.Error("Multi-root should have prefix for non-cwd roots") + } + // Both IDs should be present + if !strings.Contains(result, "add-feature") { + t.Error("Should contain first change ID") + } + if !strings.Contains(result, "fix-bug") { + t.Error("Should contain second change ID") + } +} + +func TestFormatSpecsTextMulti_MultipleRoots(t *testing.T) { + specs := []SpecInfo{ + { + ID: "auth", + Title: "Authentication", + RequirementCount: 5, + RootPath: "../lib", + }, + { + ID: "api", + Title: "API", + RequirementCount: 12, + RootPath: ".", + }, + } + + result := FormatSpecsTextMulti(specs, FormatModeMulti) + + // Spec from parent should have prefix + if !strings.Contains(result, "[../lib]") { + t.Error("Multi-root should have prefix for non-cwd roots") + } + // Both IDs should be present + if !strings.Contains(result, "api") { + t.Error("Should contain api spec") + } + if !strings.Contains(result, "auth") { + t.Error("Should contain auth spec") + } +} diff --git a/internal/list/formatters_unified.go b/internal/list/formatters_unified.go index 2f62ad06..c2c52ac2 100644 --- a/internal/list/formatters_unified.go +++ b/internal/list/formatters_unified.go @@ -143,3 +143,128 @@ func FormatAllJSON( return string(data), nil } + +// calculateItemWidths computes max ID and root path lengths for alignment. +func calculateItemWidths(items ItemList, mode FormatMode) (maxIDLen, maxRootLen int) { + for _, item := range items { + if len(item.ID()) > maxIDLen { + maxIDLen = len(item.ID()) + } + + if !mode.IsMulti() { + continue + } + + rootPath := item.RootPath() + if len(rootPath) > maxRootLen { + maxRootLen = len(rootPath) + } + } + + return maxIDLen, maxRootLen +} + +// formatItemTypeAndDetails extracts type indicator and details from an item. +func formatItemTypeAndDetails(item Item) (typeIndicator, details string) { + switch item.Type { + case ItemTypeChange: + typeIndicator = "[CHANGE]" + if item.Change != nil { + details = fmt.Sprintf("%d/%d tasks", + item.Change.TaskStatus.Completed, + item.Change.TaskStatus.Total) + } + case ItemTypeSpec: + typeIndicator = "[SPEC] " + if item.Spec != nil { + details = fmt.Sprintf("%d requirements", item.Spec.RequirementCount) + } + } + + return typeIndicator, details +} + +// FormatAllTextMulti formats all items with optional root prefix for multi-root scenarios. +func FormatAllTextMulti(items ItemList, mode FormatMode) string { + if len(items) == 0 { + return noItemsFoundMsg + } + + // Sort by ID + sort.Slice(items, func(i, j int) bool { + return items[i].ID() < items[j].ID() + }) + + maxIDLen, maxRootLen := calculateItemWidths(items, mode) + + // Build lines for each item with type indicator + lines := make([]string, 0, len(items)) + for _, item := range items { + typeIndicator, details := formatItemTypeAndDetails(item) + rootPath := item.RootPath() + + var line string + if rootPath != currentDirPath && rootPath != "" && mode.IsMulti() { + line = fmt.Sprintf("[%-*s] %-*s %s %s", + maxRootLen, rootPath, maxIDLen, item.ID(), typeIndicator, details) + } else { + line = fmt.Sprintf("%-*s %s %s", maxIDLen, item.ID(), typeIndicator, details) + } + + lines = append(lines, line) + } + + return strings.Join(lines, lineSeparator) +} + +// FormatAllLongMulti formats all items with detailed information and optional root prefix. +func FormatAllLongMulti(items ItemList, mode FormatMode) string { + if len(items) == 0 { + return noItemsFoundMsg + } + + // Sort by ID + sort.Slice(items, func(i, j int) bool { + return items[i].ID() < items[j].ID() + }) + + var lines []string + for _, item := range items { + var line string + rootPath := item.RootPath() + rootPrefix := "" + if rootPath != currentDirPath && rootPath != "" && mode.IsMulti() { + rootPrefix = fmt.Sprintf("[%s] ", rootPath) + } + + switch item.Type { + case ItemTypeChange: + if item.Change != nil { + line = fmt.Sprintf( + "%s%s [CHANGE]: %s [deltas %d] [tasks %d/%d]", + rootPrefix, + item.Change.ID, + item.Change.Title, + item.Change.DeltaCount, + item.Change.TaskStatus.Completed, + item.Change.TaskStatus.Total, + ) + } + case ItemTypeSpec: + if item.Spec != nil { + line = fmt.Sprintf( + "%s%s [SPEC]: %s [requirements %d]", + rootPrefix, + item.Spec.ID, + item.Spec.Title, + item.Spec.RequirementCount, + ) + } + } + if line != "" { + lines = append(lines, line) + } + } + + return strings.Join(lines, lineSeparator) +} diff --git a/internal/list/interactive.go b/internal/list/interactive.go index 24bc8a97..a0388fbd 100644 --- a/internal/list/interactive.go +++ b/internal/list/interactive.go @@ -60,11 +60,18 @@ const ( // Table height tableHeight = 10 + // Line number column width + lineNumberColumnWidth = 3 + // Item type string constants itemTypeAll = "all" itemTypeChange = "change" itemTypeSpec = "spec" + // Type display strings + typeDisplayChange = "CHANGE" + typeDisplaySpec = "SPEC" + // Column title constants columnTitleID = "ID" columnTitleTitle = "Title" @@ -92,6 +99,18 @@ const ( breakpointHideTitle = 80 ) +// LineNumberMode controls how line numbers are displayed in the interactive list. +type LineNumberMode int + +const ( + // LineNumberOff - no line numbers displayed + LineNumberOff LineNumberMode = iota + // LineNumberRelative - show relative distance from cursor + LineNumberRelative + // LineNumberHybrid - cursor shows absolute, others show relative + LineNumberHybrid +) + // ColumnPriority defines the priority level for table columns. // Higher priority columns are shown first when space is limited. type ColumnPriority int @@ -118,15 +137,24 @@ const ( // Column order is always: ID | Title | Deltas | Tasks (when visible) func calculateChangesColumns( width int, + lineNumberMode LineNumberMode, ) []table.Column { // Calculate available width for content (accounting for table borders/padding) // Table has approximately 4 chars of padding/borders per column const paddingPerColumn = 4 + // Prepend line number column if enabled + var lineNumCol []table.Column + if lineNumberMode != LineNumberOff { + lineNumCol = []table.Column{ + {Title: "Ln", Width: lineNumberColumnWidth}, + } + } + switch { case width >= breakpointFull: // Full width (110+): all 4 columns at default widths - return []table.Column{ + cols := []table.Column{ { Title: columnTitleID, Width: changeIDWidth, @@ -145,6 +173,8 @@ func calculateChangesColumns( }, } + return append(lineNumCol, cols...) + case width >= breakpointMedium: // Medium width (90-109): all 4 columns visible, Title narrowed titleWidth := max( @@ -453,7 +483,7 @@ func calculateTitleTruncate( switch viewType { case itemTypeChange: - cols := calculateChangesColumns(width) + cols := calculateChangesColumns(width, LineNumberOff) // Find Title column (may not be present at narrow widths) for _, col := range cols { if col.Title == columnTitleTitle { @@ -494,7 +524,7 @@ func hasHiddenColumns( switch viewType { case itemTypeChange: return len( - calculateChangesColumns(width), + calculateChangesColumns(width, LineNumberOff), ) < 4 // Full has 4 columns case itemTypeSpec: return len( @@ -509,6 +539,30 @@ func hasHiddenColumns( } } +// buildChangesRows creates table rows for changes data with the given +// title truncation and column set. The column set determines which fields +// are included in each row to match the visible columns. + +// detectMultiRootChanges returns true if any change has a non-trivial RootPath. +func detectMultiRootChanges(changes []ChangeInfo) bool { + for _, c := range changes { + if c.RootPath != "" && c.RootPath != "." { + return true + } + } + + return false +} + +// formatChangeIDWithProject formats a change ID with project prefix if in multi-root mode. +func formatChangeIDWithProject(id, rootPath string, hasMultipleRoots bool) string { + if !hasMultipleRoots || rootPath == "" || rootPath == "." { + return id + } + + return fmt.Sprintf("[%s] %s", rootPath, id) +} + // buildChangesRows creates table rows for changes data with the given // title truncation and column set. The column set determines which fields // are included in each row to match the visible columns. @@ -516,18 +570,40 @@ func buildChangesRows( changes []ChangeInfo, titleTruncate int, numColumns int, + lineNumberMode LineNumberMode, + cursor int, ) []table.Row { rows := make([]table.Row, len(changes)) + + // Detect if we have multiple roots to show project prefix in ID column + hasMultipleRoots := detectMultiRootChanges(changes) + + // Calculate effective number of data columns (excluding line number column) + dataColumns := numColumns + if lineNumberMode != LineNumberOff { + dataColumns = numColumns - 1 + } + for i, change := range changes { tasksStatus := fmt.Sprintf("%d/%d", change.TaskStatus.Completed, change.TaskStatus.Total) - switch numColumns { + // Format ID with project prefix if in multi-root mode + displayID := formatChangeIDWithProject(change.ID, change.RootPath, hasMultipleRoots) + + // Calculate line number + var lineNumStr string + if lineNumberMode != LineNumberOff { + lineNum := calculateLineNumberValue(i, cursor, lineNumberMode) + lineNumStr = fmt.Sprintf("%d", lineNum) + } + + switch dataColumns { case 4: // Full: ID, Title, Deltas, Tasks - rows[i] = table.Row{ - change.ID, + row := table.Row{ + displayID, tui.TruncateString( change.Title, titleTruncate, @@ -538,20 +614,30 @@ func buildChangesRows( ), tasksStatus, } + if lineNumberMode != LineNumberOff { + rows[i] = append(table.Row{lineNumStr}, row...) + } else { + rows[i] = row + } case 3: // 3 columns without Title: ID, Deltas, Tasks - rows[i] = table.Row{ - change.ID, + row := table.Row{ + displayID, fmt.Sprintf( "%d", change.DeltaCount, ), tasksStatus, } + if lineNumberMode != LineNumberOff { + rows[i] = append(table.Row{lineNumStr}, row...) + } else { + rows[i] = row + } default: // Minimal 2 columns: ID, Tasks only rows[i] = table.Row{ - change.ID, + displayID, tasksStatus, } } @@ -560,6 +646,26 @@ func buildChangesRows( return rows } +// detectMultiRootSpecs returns true if any spec has a non-trivial RootPath. +func detectMultiRootSpecs(specs []SpecInfo) bool { + for _, s := range specs { + if s.RootPath != "" && s.RootPath != "." { + return true + } + } + + return false +} + +// formatSpecIDWithProject formats a spec ID with project prefix if in multi-root mode. +func formatSpecIDWithProject(id, rootPath string, hasMultipleRoots bool) string { + if !hasMultipleRoots || rootPath == "" || rootPath == "." { + return id + } + + return fmt.Sprintf("[%s] %s", rootPath, id) +} + // buildSpecsRows creates table rows for specs data with the given // title truncation and number of columns. func buildSpecsRows( @@ -568,12 +674,19 @@ func buildSpecsRows( numColumns int, ) []table.Row { rows := make([]table.Row, len(specs)) + + // Detect if we have multiple roots to show project prefix in ID column + hasMultipleRoots := detectMultiRootSpecs(specs) + for i, spec := range specs { + // Format ID with project prefix if in multi-root mode + displayID := formatSpecIDWithProject(spec.ID, spec.RootPath, hasMultipleRoots) + switch numColumns { case 3: // Full: ID, Title, Requirements rows[i] = table.Row{ - spec.ID, + displayID, tui.TruncateString( spec.Title, titleTruncate, @@ -586,7 +699,7 @@ func buildSpecsRows( default: // Minimal: ID, Title only rows[i] = table.Row{ - spec.ID, + displayID, tui.TruncateString( spec.Title, titleTruncate, @@ -598,6 +711,27 @@ func buildSpecsRows( return rows } +// detectMultiRootItems returns true if any item has a non-trivial RootPath. +func detectMultiRootItems(items ItemList) bool { + for _, item := range items { + rootPath := item.RootPath() + if rootPath != "" && rootPath != "." { + return true + } + } + + return false +} + +// formatItemIDWithProject formats an item ID with project prefix if in multi-root mode. +func formatItemIDWithProject(id, rootPath string, hasMultipleRoots bool) string { + if !hasMultipleRoots || rootPath == "" || rootPath == "." { + return id + } + + return fmt.Sprintf("[%s] %s", rootPath, id) +} + // buildUnifiedRows creates table rows for unified (all items) view with the // given title truncation and number of columns. func buildUnifiedRows( @@ -606,11 +740,18 @@ func buildUnifiedRows( numColumns int, ) []table.Row { rows := make([]table.Row, len(items)) + + // Detect if we have multiple roots to show project prefix in ID column + hasMultipleRoots := detectMultiRootItems(items) + for i, item := range items { + // Format ID with project prefix if in multi-root mode + displayID := formatItemIDWithProject(item.ID(), item.RootPath(), hasMultipleRoots) + var typeStr, details string switch item.Type { case ItemTypeChange: - typeStr = "CHANGE" + typeStr = typeDisplayChange if item.Change != nil { details = fmt.Sprintf( "Tasks: %d/%d 🔺 %d", @@ -620,7 +761,7 @@ func buildUnifiedRows( ) } case ItemTypeSpec: - typeStr = "SPEC" + typeStr = typeDisplaySpec if item.Spec != nil { details = fmt.Sprintf( "Reqs: %d", @@ -633,7 +774,7 @@ func buildUnifiedRows( case 4: // Full: ID, Type, Title, Details rows[i] = table.Row{ - item.ID(), + displayID, typeStr, tui.TruncateString( item.Title(), @@ -644,7 +785,7 @@ func buildUnifiedRows( default: // Narrow/Minimal: ID, Type, Title (no Details) rows[i] = table.Row{ - item.ID(), + displayID, typeStr, tui.TruncateString( item.Title(), @@ -664,7 +805,8 @@ type interactiveModel struct { copied bool quitting bool archiveRequested bool - prRequested bool // true when P (pr) hotkey was pressed + selectedRootPath string // absolute path to root for archive/PR workflows + prRequested bool // true when P (pr) hotkey was pressed err error helpText string minimalFooter string @@ -685,6 +827,7 @@ type interactiveModel struct { changesData []ChangeInfo // original changes data for changes/archive views specsData []SpecInfo // original specs data for specs view countPrefixState tui.CountPrefixState // vim-style count prefix state + lineNumberMode LineNumberMode // line number display mode (off, relative, hybrid) } // Init initializes the model @@ -727,6 +870,11 @@ func (m *interactiveModel) Update( } m.showHelp = false + // Update line numbers after count-prefix navigation + if m.lineNumberMode != LineNumberOff { + m.updateLineNumbers() + } + return m, nil } // Key was handled (digit or ESC) but not a nav key @@ -776,6 +924,11 @@ func (m *interactiveModel) Update( return m, nil + case "#": + m.cycleLineNumberMode() + + return m, nil + case "?": // Toggle help display m.showHelp = !m.showHelp @@ -806,13 +959,17 @@ func (m *interactiveModel) Update( return m, nil } - // Update table with key events + prevCursor := m.table.Cursor() m.table, cmd = m.table.Update(msg) + if m.lineNumberMode != LineNumberOff && m.table.Cursor() != prevCursor { + m.updateLineNumbers() + } + return m, cmd } -// handleEnter handles the enter key press for copying selected ID +// handleEnter handles the enter key press for copying selected path // or selecting in selection mode func (m *interactiveModel) handleEnter() { cursor := m.table.Cursor() @@ -826,8 +983,19 @@ func (m *interactiveModel) handleEnter() { return } - // ID is in first column for all modes - m.selectedID = row[0] + // ID column index depends on whether line numbers are enabled + // When line numbers are on, first column is line number, ID is second + idColumn := 0 + if m.lineNumberMode != LineNumberOff { + idColumn = 1 + } + + if len(row) <= idColumn { + return + } + + itemID := row[idColumn] + m.selectedID = itemID // In selection mode, just select without copying to clipboard if m.selectionMode { @@ -839,14 +1007,119 @@ func (m *interactiveModel) handleEnter() { return } - // Otherwise, copy to clipboard + // Build the full path to copy to clipboard + copyPath := m.buildCopyPath(itemID, row) + + // Copy to clipboard m.copied = true - err := tui.CopyToClipboard(m.selectedID) + err := tui.CopyToClipboard(copyPath) if err != nil { m.err = err } } +// buildCopyPath builds the path to copy for the selected item. +// Returns path relative to cwd (e.g., "spectr/changes//proposal.md") +func (m *interactiveModel) buildCopyPath(itemID string, row table.Row) string { + // Determine the item type + var itemType, rootPath string + + switch m.itemType { + case itemTypeChange: + // Find the change in changesData to get root path + // Compare using formatted ID (display format) since itemID comes from display + hasMultipleRoots := detectMultiRootChanges(m.changesData) + for _, change := range m.changesData { + formattedID := formatChangeIDWithProject(change.ID, change.RootPath, hasMultipleRoots) + if formattedID == itemID { + // Use raw ID for path construction + return buildChangePath(change.RootPath, change.ID) + } + } + + // Fallback for single-root mode where itemID equals raw ID + return buildChangePath(rootPath, itemID) + + case itemTypeSpec: + // Find the spec in specsData to get root path + // Compare using formatted ID (display format) since itemID comes from display + hasMultipleRoots := detectMultiRootSpecs(m.specsData) + for _, spec := range m.specsData { + formattedID := formatSpecIDWithProject(spec.ID, spec.RootPath, hasMultipleRoots) + if formattedID == itemID { + // Use raw ID for path construction + return buildSpecPath(spec.RootPath, spec.ID) + } + } + + // Fallback for single-root mode where itemID equals raw ID + return buildSpecPath(rootPath, itemID) + + case itemTypeAll: + // Calculate column offset when line numbers are enabled + colOffset := 0 + if m.lineNumberMode != LineNumberOff { + colOffset = 1 + } + + // In unified mode, check the type column (column after ID) + typeColIdx := colOffset + 1 + if len(row) > typeColIdx { + itemType = row[typeColIdx] + } + + // Find the item in allItems to get root path + // Compare using formatted ID (display format) since itemID comes from display + hasMultipleRoots := detectMultiRootItems(m.allItems) + for i := range m.allItems { + formattedID := formatItemIDWithProject( + m.allItems[i].ID(), + m.allItems[i].RootPath(), + hasMultipleRoots, + ) + if formattedID == itemID { + rootPath = m.allItems[i].RootPath() + rawID := m.allItems[i].ID() + if itemType == typeDisplaySpec { + return buildSpecPath(rootPath, rawID) + } + + return buildChangePath(rootPath, rawID) + } + } + + // Fallback for single-root mode + if itemType == typeDisplaySpec { + return buildSpecPath(rootPath, itemID) + } + + return buildChangePath(rootPath, itemID) + } + + // Fallback to just the ID + return itemID +} + +// buildChangePath builds the path for a change. +func buildChangePath(rootPath, changeID string) string { + // If rootPath is "." or empty, use current directory + if rootPath == "" || rootPath == "." { + return fmt.Sprintf("spectr/changes/%s", changeID) + } + // Otherwise prefix with root path + return fmt.Sprintf("%s/spectr/changes/%s", rootPath, changeID) +} + +// buildSpecPath builds the path for a spec. +func buildSpecPath(rootPath, specID string) string { + // If rootPath is "." or empty, use current directory + if rootPath == "" || rootPath == "." { + return fmt.Sprintf("spectr/specs/%s", specID) + } + // Otherwise prefix with root path + return fmt.Sprintf("%s/spectr/specs/%s", rootPath, specID) +} + // handleEdit handles the 'e' key press for opening file in editor func (m *interactiveModel) handleEdit() (tea.Model, tea.Cmd) { // Get the selected row @@ -861,6 +1134,12 @@ func (m *interactiveModel) handleEdit() (tea.Model, tea.Cmd) { return m, nil } + // Calculate column offset when line numbers are enabled + colOffset := 0 + if m.lineNumberMode != LineNumberOff { + colOffset = 1 + } + var itemID string var editItemType string @@ -868,20 +1147,29 @@ func (m *interactiveModel) handleEdit() (tea.Model, tea.Cmd) { switch m.itemType { case itemTypeAll: // In unified mode, need to check the item type - itemID = row[0] - itemTypeStr := row[1] // Type is second column in unified mode - if itemTypeStr == "SPEC" { + if len(row) <= colOffset+1 { + return m, nil + } + itemID = row[colOffset] + itemTypeStr := row[colOffset+1] // Type is column after ID in unified mode + if itemTypeStr == typeDisplaySpec { editItemType = itemTypeSpec } else { editItemType = itemTypeChange } case itemTypeSpec: // In spec-only mode - itemID = row[0] + if len(row) <= colOffset { + return m, nil + } + itemID = row[colOffset] editItemType = itemTypeSpec case itemTypeChange: // In change-only mode - itemID = row[0] + if len(row) <= colOffset { + return m, nil + } + itemID = row[colOffset] editItemType = itemTypeChange default: // Unknown mode, no editing allowed @@ -967,26 +1255,45 @@ func (m *interactiveModel) handleArchive() (tea.Model, tea.Cmd) { return m, nil } + // Calculate column offset when line numbers are enabled + colOffset := 0 + if m.lineNumberMode != LineNumberOff { + colOffset = 1 + } + // Determine if item is a change based on mode switch m.itemType { case itemTypeSpec: // Can't archive specs return m, nil case itemTypeChange: - // In change mode, all items are changes - m.selectedID = row[0] - m.archiveRequested = true - - return m, tea.Quit - case itemTypeAll: - // In unified mode, check the type column - if len(row) > 1 && row[1] == "CHANGE" { - m.selectedID = row[0] + // In change mode, look up the actual ChangeInfo to get raw ID and root path + // When search is active, we need to find the change matching the displayed row + change := m.findChangeForCursor(cursor, row, colOffset) + if change != nil { + m.selectedID = change.ID // Raw ID without prefix + m.selectedRootPath = change.RootAbsPath m.archiveRequested = true return m, tea.Quit } - // Not a change, do nothing + + return m, nil + case itemTypeAll: + // In unified mode, check the type column (column after ID) + typeColIdx := colOffset + 1 + if len(row) > typeColIdx && row[typeColIdx] == typeDisplayChange { + // Find the change in allItems matching the displayed row + change := m.findChangeInAllItems(row, colOffset) + if change != nil { + m.selectedID = change.ID + m.selectedRootPath = change.RootAbsPath + m.archiveRequested = true + + return m, tea.Quit + } + } + // Not a change or not found return m, nil } @@ -1012,10 +1319,23 @@ func (m *interactiveModel) handlePR() (tea.Model, tea.Cmd) { return m, nil } - m.selectedID = row[0] - m.prRequested = true + // Calculate column offset when line numbers are enabled + colOffset := 0 + if m.lineNumberMode != LineNumberOff { + colOffset = 1 + } + + // Look up the actual ChangeInfo to get raw ID and root path + change := m.findChangeForCursor(cursor, row, colOffset) + if change != nil { + m.selectedID = change.ID // Raw ID without prefix + m.selectedRootPath = change.RootAbsPath + m.prRequested = true - return m, tea.Quit + return m, tea.Quit + } + + return m, nil } // rebuildUnifiedTable rebuilds the table based on current filter @@ -1066,7 +1386,7 @@ func rebuildUnifiedTable( } m.helpText = fmt.Sprintf( "↑/↓/j/k: navigate (try 9j) | Enter: copy ID | e: edit | "+ - "a: archive | t: filter (%s) | /: search | q: quit", + "a: archive | t: filter (%s) | #: line numbers | /: search | q: quit", filterDesc, ) m.minimalFooter = fmt.Sprintf( @@ -1195,6 +1515,68 @@ func (m *interactiveModel) toggleSearchMode() { } } +func (m *interactiveModel) cycleLineNumberMode() { + switch m.lineNumberMode { + case LineNumberOff: + m.lineNumberMode = LineNumberRelative + case LineNumberRelative: + m.lineNumberMode = LineNumberHybrid + case LineNumberHybrid: + m.lineNumberMode = LineNumberOff + } + + // Trigger table rebuild to add/remove line number column + m.rebuildTableForWidth() +} + +func abs(n int) int { + if n < 0 { + return -n + } + + return n +} + +// calculateLineNumberValue returns the display value for a line number +func calculateLineNumberValue(rowIdx, cursorIdx int, mode LineNumberMode) int { + switch mode { + case LineNumberOff: + return 0 + case LineNumberRelative: + return abs(rowIdx - cursorIdx) + case LineNumberHybrid: + if rowIdx == cursorIdx { + return cursorIdx + 1 + } + + return abs(rowIdx - cursorIdx) + } + + return 0 +} + +func (m *interactiveModel) updateLineNumbers() { + if m.lineNumberMode == LineNumberOff { + return + } + + cursor := m.table.Cursor() + rows := m.table.Rows() + + // Create new rows with updated line numbers + updatedRows := make([]table.Row, len(rows)) + for i := range rows { + updatedRows[i] = make(table.Row, len(rows[i])) + copy(updatedRows[i], rows[i]) + if len(updatedRows[i]) > 0 { + lineNum := calculateLineNumberValue(i, cursor, m.lineNumberMode) + updatedRows[i][0] = fmt.Sprintf("%d", lineNum) + } + } + + m.table.SetRows(updatedRows) +} + // rebuildTableForWidth rebuilds the table with columns adjusted for the // current terminal width. This is called when the terminal is resized. // It preserves cursor position and search state during the rebuild. @@ -1243,7 +1625,7 @@ func (m *interactiveModel) rebuildChangesTable( return } - columns := calculateChangesColumns(width) + columns := calculateChangesColumns(width, m.lineNumberMode) titleTruncate := calculateTitleTruncate( itemTypeChange, width, @@ -1252,6 +1634,8 @@ func (m *interactiveModel) rebuildChangesTable( m.changesData, titleTruncate, len(columns), + m.lineNumberMode, + m.table.Cursor(), ) t := table.New( @@ -1302,19 +1686,148 @@ func (m *interactiveModel) getEditFilePath( itemID string, itemType string, ) string { + // Look up the item's RootPath from the data + rootPath := m.lookupRootPath(itemID, itemType) + if itemType == itemTypeSpec { + // Build the absolute path including RootPath if present + if rootPath == "" || rootPath == "." { + return fmt.Sprintf( + "%s/spectr/specs/%s/spec.md", + m.projectPath, itemID, + ) + } + return fmt.Sprintf( - "%s/spectr/specs/%s/spec.md", + "%s/%s/spectr/specs/%s/spec.md", + m.projectPath, rootPath, itemID, + ) + } + + // For changes + if rootPath == "" || rootPath == "." { + return fmt.Sprintf( + "%s/spectr/changes/%s/proposal.md", m.projectPath, itemID, ) } return fmt.Sprintf( - "%s/spectr/changes/%s/proposal.md", - m.projectPath, itemID, + "%s/%s/spectr/changes/%s/proposal.md", + m.projectPath, rootPath, itemID, ) } +// lookupRootPath finds the RootPath for an item by searching the appropriate data source. +// It checks specsData/changesData first, then falls back to allItems for unified mode. +func (m *interactiveModel) lookupRootPath(itemID, itemType string) string { + var rootPath string + var found bool + + if itemType == itemTypeSpec { + // First try specsData + for _, spec := range m.specsData { + if spec.ID == itemID { + rootPath = spec.RootPath + found = true + + break + } + } + } else { + // First try changesData + for _, change := range m.changesData { + if change.ID == itemID { + rootPath = change.RootPath + found = true + + break + } + } + } + + // If not found in dedicated data, search allItems (unified mode) + if !found { + for i := range m.allItems { + if m.allItems[i].ID() == itemID { + rootPath = m.allItems[i].RootPath() + + break + } + } + } + + return rootPath +} + +// findChangeForCursor finds the ChangeInfo for the given cursor position. +// When search filtering is active, it matches against the displayed row's ID. +func (m *interactiveModel) findChangeForCursor( + cursor int, + row table.Row, + colOffset int, +) *ChangeInfo { + // If no search filter is active and cursor is valid, use direct index + if m.searchQuery == "" && cursor < len(m.changesData) { + return &m.changesData[cursor] + } + + // Search is active - need to find the change matching the displayed ID + if len(row) <= colOffset { + return nil + } + + displayedID := row[colOffset] + + // The displayed ID may have a project prefix like "[project] change-id" + // Search through changesData to find matching change + for i := range m.changesData { + change := &m.changesData[i] + formattedID := formatChangeIDWithProject( + change.ID, + change.RootPath, + detectMultiRootChanges(m.changesData), + ) + if formattedID == displayedID { + return change + } + } + + return nil +} + +// findChangeInAllItems finds the ChangeInfo matching a row in unified mode. +func (m *interactiveModel) findChangeInAllItems( + row table.Row, + colOffset int, +) *ChangeInfo { + if len(row) <= colOffset { + return nil + } + + displayedID := row[colOffset] + hasMultipleRoots := detectMultiRootItems(m.allItems) + + // Search through allItems to find matching change + for i := range m.allItems { + item := &m.allItems[i] + if item.Type != ItemTypeChange || item.Change == nil { + continue + } + + formattedID := formatItemIDWithProject( + item.Change.ID, + item.Change.RootPath, + hasMultipleRoots, + ) + if formattedID == displayedID { + return item.Change + } + } + + return nil +} + // View renders the model func (m *interactiveModel) View() string { if m.quitting { @@ -1387,11 +1900,18 @@ func (m *interactiveModel) View() string { footer += " | (some columns hidden)" } - // Append count prefix indicator when active if m.countPrefixState.IsActive() { footer += fmt.Sprintf(" | count: %s_", m.countPrefixState.String()) } + if m.lineNumberMode != LineNumberOff { + modeStr := "rel" + if m.lineNumberMode == LineNumberHybrid { + modeStr = "hyb" + } + footer += fmt.Sprintf(" | ln: %s", modeStr) + } + view += m.table.View() + "\n" + footer + "\n" // Display error message if present, but keep TUI active @@ -1406,17 +1926,19 @@ func (m *interactiveModel) View() string { } // RunInteractiveChanges runs the interactive table for changes. -// Returns (archiveID, prID, error): +// Returns (archiveID, archiveRootPath, prID, prRootPath, error): // - archiveID is set if archive was requested via 'a' key +// - archiveRootPath is the absolute path to the spectr root for archive // - prID is set if PR mode was requested via 'P' key -// - Both are empty if user quit or cancelled +// - prRootPath is the absolute path to the spectr root for PR +// - All are empty if user quit or cancelled func RunInteractiveChanges( changes []ChangeInfo, projectPath string, stdoutMode bool, -) (archiveID, prID string, err error) { +) (archiveID, archiveRootPath, prID, prRootPath string, err error) { if len(changes) == 0 { - return "", "", nil + return "", "", "", "", nil } // Use default full-width columns initially (terminalWidth=0 means unknown) @@ -1424,6 +1946,7 @@ func RunInteractiveChanges( // WindowSizeMsg will trigger a rebuild with correct responsive columns columns := calculateChangesColumns( breakpointFull, + LineNumberRelative, ) titleTruncate := calculateTitleTruncate( itemTypeChange, @@ -1434,6 +1957,8 @@ func RunInteractiveChanges( changes, titleTruncate, len(columns), + LineNumberRelative, + 0, ) t := table.New( @@ -1446,16 +1971,17 @@ func RunInteractiveChanges( tui.ApplyTableStyles(&t) m := &interactiveModel{ - table: t, - itemType: itemTypeChange, - projectPath: projectPath, - searchInput: newTextInput(), - allRows: rows, - terminalWidth: 0, // Will be set by WindowSizeMsg - changesData: changes, // Store for rebuild on resize - stdoutMode: stdoutMode, // Output to stdout instead of clipboard + table: t, + itemType: itemTypeChange, + projectPath: projectPath, + searchInput: newTextInput(), + allRows: rows, + terminalWidth: 0, // Will be set by WindowSizeMsg + changesData: changes, // Store for rebuild on resize + stdoutMode: stdoutMode, // Output to stdout instead of clipboard + lineNumberMode: LineNumberRelative, // Default to relative line numbers helpText: "↑/↓/j/k: navigate (try 9j) | Enter: copy ID | e: edit | " + - "a: archive | P: pr | /: search | q: quit", + "a: archive | P: pr | #: line numbers | /: search | q: quit", minimalFooter: fmt.Sprintf( "showing: %d | project: %s | ?: help", len(rows), @@ -1466,7 +1992,7 @@ func RunInteractiveChanges( p := tea.NewProgram(m) finalModel, runErr := p.Run() if runErr != nil { - return "", "", fmt.Errorf( + return "", "", "", "", fmt.Errorf( errInteractiveModeFormat, runErr, ) @@ -1484,19 +2010,19 @@ func RunInteractiveChanges( ) } - // Return archive ID if archive was requested + // Return archive ID and root path if archive was requested if fm.archiveRequested && fm.selectedID != "" { - return fm.selectedID, "", nil + return fm.selectedID, fm.selectedRootPath, "", "", nil } - // Return PR ID if PR was requested + // Return PR ID and root path if PR was requested if fm.prRequested && fm.selectedID != "" { - return "", fm.selectedID, nil + return "", "", fm.selectedID, fm.selectedRootPath, nil } } - return "", "", nil + return "", "", "", "", nil } // RunInteractiveArchive runs the interactive table for @@ -1514,6 +2040,7 @@ func RunInteractiveArchive( // WindowSizeMsg will trigger a rebuild with correct responsive columns columns := calculateChangesColumns( breakpointFull, + LineNumberRelative, ) titleTruncate := calculateTitleTruncate( itemTypeChange, @@ -1524,6 +2051,8 @@ func RunInteractiveArchive( changes, titleTruncate, len(columns), + LineNumberRelative, + 0, ) t := table.New( @@ -1536,15 +2065,16 @@ func RunInteractiveArchive( tui.ApplyTableStyles(&t) m := &interactiveModel{ - table: t, - itemType: itemTypeChange, - projectPath: projectPath, - searchInput: newTextInput(), - allRows: rows, - terminalWidth: 0, // Will be set by WindowSizeMsg - changesData: changes, // Store for rebuild on resize - selectionMode: true, // Enter selects without copying - helpText: "↑/↓/j/k: navigate (try 9j) | Enter: select | /: search | q: quit", + table: t, + itemType: itemTypeChange, + projectPath: projectPath, + searchInput: newTextInput(), + allRows: rows, + terminalWidth: 0, // Will be set by WindowSizeMsg + changesData: changes, // Store for rebuild on resize + selectionMode: true, // Enter selects without copying + lineNumberMode: LineNumberRelative, // Default to relative line numbers + helpText: "↑/↓/j/k: navigate (try 9j) | Enter: select | #: line numbers | /: search | q: quit", minimalFooter: fmt.Sprintf( "showing: %d | project: %s | ?: help", len(rows), @@ -1619,16 +2149,17 @@ func RunInteractiveSpecs( tui.ApplyTableStyles(&t) m := &interactiveModel{ - table: t, - itemType: itemTypeSpec, - projectPath: projectPath, - searchInput: newTextInput(), - allRows: rows, - terminalWidth: 0, // Will be set by WindowSizeMsg - specsData: specs, // Store for rebuild on resize - stdoutMode: stdoutMode, // Output to stdout instead of clipboard + table: t, + itemType: itemTypeSpec, + projectPath: projectPath, + searchInput: newTextInput(), + allRows: rows, + terminalWidth: 0, // Will be set by WindowSizeMsg + specsData: specs, // Store for rebuild on resize + stdoutMode: stdoutMode, // Output to stdout instead of clipboard + lineNumberMode: LineNumberRelative, // Default to relative line numbers helpText: "↑/↓/j/k: navigate (try 9j) | Enter: copy ID | e: edit | " + - "/: search | q: quit", + "#: line numbers | /: search | q: quit", minimalFooter: fmt.Sprintf( "showing: %d | project: %s | ?: help", len(specs), @@ -1697,17 +2228,18 @@ func RunInteractiveAll( tui.ApplyTableStyles(&t) m := &interactiveModel{ - table: t, - itemType: itemTypeAll, - projectPath: projectPath, - allItems: items, - filterType: nil, // Start with all items visible - searchInput: newTextInput(), - allRows: rows, - terminalWidth: 0, // Will be set by WindowSizeMsg - stdoutMode: stdoutMode, // Output to stdout instead of clipboard + table: t, + itemType: itemTypeAll, + projectPath: projectPath, + allItems: items, + filterType: nil, // Start with all items visible + searchInput: newTextInput(), + allRows: rows, + terminalWidth: 0, // Will be set by WindowSizeMsg + stdoutMode: stdoutMode, // Output to stdout instead of clipboard + lineNumberMode: LineNumberRelative, // Default to relative line numbers helpText: "↑/↓/j/k: navigate (try 9j) | Enter: copy ID | e: edit | " + - "a: archive | t: filter (all) | /: search | q: quit", + "a: archive | t: filter (all) | #: line numbers | /: search | q: quit", minimalFooter: fmt.Sprintf( "showing: %d | project: %s | ?: help", len(rows), diff --git a/internal/list/interactive_test.go b/internal/list/interactive_test.go index 0b5d25fc..94f2ca7e 100644 --- a/internal/list/interactive_test.go +++ b/internal/list/interactive_test.go @@ -22,7 +22,7 @@ func TestRunInteractiveChanges_EmptyList( t *testing.T, ) { var changes []ChangeInfo - archiveID, prID, err := RunInteractiveChanges( + archiveID, archiveRootPath, prID, prRootPath, err := RunInteractiveChanges( changes, "/tmp/test-project", false, @@ -39,12 +39,24 @@ func TestRunInteractiveChanges_EmptyList( archiveID, ) } + if archiveRootPath != "" { + t.Errorf( + "RunInteractiveChanges with empty list should return empty archive root path, got: %s", + archiveRootPath, + ) + } if prID != "" { t.Errorf( "RunInteractiveChanges with empty list should return empty PR ID, got: %s", prID, ) } + if prRootPath != "" { + t.Errorf( + "RunInteractiveChanges with empty list should return empty PR root path, got: %s", + prRootPath, + ) + } } func TestRunInteractiveSpecs_EmptyList( @@ -447,7 +459,7 @@ func TestRunInteractiveAll_ValidData( // This test verifies that the function can be called without error // Actual interactive testing would require terminal simulation items := ItemList{ - NewChangeItem(ChangeInfo{ + NewChangeItem(&ChangeInfo{ ID: "add-test-feature", Title: "Add test feature", DeltaCount: 2, @@ -471,7 +483,7 @@ func TestRunInteractiveAll_ValidData( func TestHandleToggleFilter(t *testing.T) { // Create a model with all items items := ItemList{ - NewChangeItem(ChangeInfo{ + NewChangeItem(&ChangeInfo{ ID: "change-1", Title: "Change 1", DeltaCount: 1, @@ -816,7 +828,7 @@ func TestEditorOpensInUnifiedMode(t *testing.T) { ) items := ItemList{ - NewChangeItem(ChangeInfo{ + NewChangeItem(&ChangeInfo{ ID: changeID, Title: "Test Change", DeltaCount: 2, @@ -949,10 +961,27 @@ func TestHandleArchive_ChangeMode(t *testing.T) { table.WithHeight(10), ) + // Create changesData to match the table rows + changesData := []ChangeInfo{ + { + ID: "test-change-1", + Title: "Test Change 1", + DeltaCount: 2, + RootAbsPath: "/tmp/test", + }, + { + ID: "test-change-2", + Title: "Test Change 2", + DeltaCount: 1, + RootAbsPath: "/tmp/test", + }, + } + model := &interactiveModel{ itemType: "change", projectPath: "/tmp/test", table: tbl, + changesData: changesData, } // Call handleArchive @@ -1072,10 +1101,26 @@ func TestHandleArchive_UnifiedMode_Change( table.WithHeight(10), ) + // Create allItems to match the table rows + changeInfo := &ChangeInfo{ + ID: interactiveTestChangeID, + Title: "Test Change", + RootAbsPath: "/tmp/test", + } + specInfo := &SpecInfo{ + ID: interactiveTestSpecID, + Title: "Test Spec", + } + allItems := ItemList{ + {Type: ItemTypeChange, Change: changeInfo}, + {Type: ItemTypeSpec, Spec: specInfo}, + } + model := &interactiveModel{ itemType: "all", projectPath: "/tmp/test", table: tbl, + allItems: allItems, } // Call handleArchive (cursor is on first row which is CHANGE) @@ -1199,10 +1244,27 @@ func TestHandlePR_ChangeMode(t *testing.T) { table.WithHeight(10), ) + // Create changesData to match the table rows + changesData := []ChangeInfo{ + { + ID: "test-change-1", + Title: "Test Change 1", + DeltaCount: 2, + RootAbsPath: "/tmp/test", + }, + { + ID: "test-change-2", + Title: "Test Change 2", + DeltaCount: 1, + RootAbsPath: "/tmp/test", + }, + } + model := &interactiveModel{ itemType: "change", projectPath: "/tmp/test", table: tbl, + changesData: changesData, } // Call handlePR @@ -2152,6 +2214,7 @@ func TestCalculateChangesColumns_FullWidth( func(t *testing.T) { cols := calculateChangesColumns( width, + LineNumberOff, ) if len(cols) != 4 { @@ -2228,6 +2291,7 @@ func TestCalculateChangesColumns_MediumWidth( func(t *testing.T) { cols := calculateChangesColumns( width, + LineNumberOff, ) if len(cols) != 4 { @@ -2282,6 +2346,7 @@ func TestCalculateChangesColumns_NarrowTitleWidth( func(t *testing.T) { cols := calculateChangesColumns( width, + LineNumberOff, ) if len(cols) != 4 { @@ -2344,6 +2409,7 @@ func TestCalculateChangesColumns_NarrowWidth( func(t *testing.T) { cols := calculateChangesColumns( width, + LineNumberOff, ) if len(cols) != 3 { @@ -2389,6 +2455,7 @@ func TestCalculateChangesColumns_MinimalWidth( func(t *testing.T) { cols := calculateChangesColumns( width, + LineNumberOff, ) if len(cols) != 2 { @@ -3307,6 +3374,7 @@ func TestColumnCountsByBreakpoint(t *testing.T) { case itemTypeChange: cols = calculateChangesColumns( tt.width, + LineNumberOff, ) case itemTypeSpec: cols = calculateSpecsColumns( @@ -3373,6 +3441,8 @@ func TestBuildChangesRows_ResponsiveColumns( changes, 30, tt.numColumns, + LineNumberOff, + 0, ) if len(rows) != len(changes) { @@ -3460,7 +3530,7 @@ func TestBuildUnifiedRows_ResponsiveColumns( t *testing.T, ) { items := ItemList{ - NewChangeItem(ChangeInfo{ + NewChangeItem(&ChangeInfo{ ID: interactiveTestChangeID, Title: "Test Change", DeltaCount: 2, @@ -3672,7 +3742,7 @@ func TestWindowSizeMsg_TriggersRebuild( // At width 75, changes view should have 2 columns (narrow breakpoint) expectedColCount := len( - calculateChangesColumns(75), + calculateChangesColumns(75, LineNumberOff), ) actualColCount := len(m.table.Columns()) @@ -3842,6 +3912,7 @@ func TestTitleWidthMinimums(t *testing.T) { // So we check Tasks column width instead changesCols := calculateChangesColumns( width, + LineNumberOff, ) // At minimal widths, changes only has ID and Tasks (no Title) // Verify Tasks column (index 1) has reasonable width @@ -4374,3 +4445,702 @@ func TestInteractiveModel_CountPrefixSearchModeSwitch(t *testing.T) { tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC}) tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second*2)) } + +func TestCycleLineNumberMode(t *testing.T) { + model := &interactiveModel{ + lineNumberMode: LineNumberOff, + } + + tests := []struct { + name string + expected LineNumberMode + }{ + {"off to relative", LineNumberRelative}, + {"relative to hybrid", LineNumberHybrid}, + {"hybrid to off", LineNumberOff}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + model.cycleLineNumberMode() + if model.lineNumberMode != tt.expected { + t.Errorf( + "Expected lineNumberMode to be %v, got %v", + tt.expected, + model.lineNumberMode, + ) + } + }) + } +} + +func TestCycleLineNumberMode_KeyHandler(t *testing.T) { + columns := []table.Column{ + {Title: "ID", Width: changeIDWidth}, + {Title: "Title", Width: changeTitleWidth}, + {Title: "Deltas", Width: changeDeltaWidth}, + {Title: "Tasks", Width: changeTasksWidth}, + } + rows := []table.Row{ + {"change-1", "Change 1", "2", "3/5"}, + } + tbl := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(10), + ) + + model := &interactiveModel{ + itemType: "change", + projectPath: "/tmp/test", + table: tbl, + lineNumberMode: LineNumberOff, + } + + updatedModel, _ := model.Update( + tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune{'#'}, + }, + ) + m, ok := updatedModel.(*interactiveModel) + if !ok { + t.Fatal("Expected interactiveModel type") + } + if m.lineNumberMode != LineNumberRelative { + t.Errorf( + "Expected lineNumberMode to be LineNumberRelative, got %v", + m.lineNumberMode, + ) + } +} + +func TestCalculateLineNumber(t *testing.T) { + tests := []struct { + name string + mode LineNumberMode + rowIdx int + cursorIdx int + expectedNum int + }{ + {"relative - cursor row", LineNumberRelative, 5, 5, 0}, + {"relative - above cursor", LineNumberRelative, 3, 5, 2}, + {"relative - below cursor", LineNumberRelative, 7, 5, 2}, + {"hybrid - cursor row", LineNumberHybrid, 5, 5, 6}, + {"hybrid - above cursor", LineNumberHybrid, 3, 5, 2}, + {"hybrid - below cursor", LineNumberHybrid, 7, 5, 2}, + {"off mode", LineNumberOff, 5, 5, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := calculateLineNumberValue(tt.rowIdx, tt.cursorIdx, tt.mode) + if result != tt.expectedNum { + t.Errorf( + "calculateLineNumberValue(%d, %d, %v) = %d, want %d", + tt.rowIdx, + tt.cursorIdx, + tt.mode, + result, + tt.expectedNum, + ) + } + }) + } +} + +func TestLineNumberFooterIndicator(t *testing.T) { + columns := []table.Column{ + {Title: "ID", Width: changeIDWidth}, + {Title: "Title", Width: changeTitleWidth}, + {Title: "Deltas", Width: changeDeltaWidth}, + {Title: "Tasks", Width: changeTasksWidth}, + } + rows := []table.Row{ + {"change-1", "Change 1", "2", "3/5"}, + } + tbl := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(10), + ) + + tests := []struct { + name string + mode LineNumberMode + wantSubstr string + notWantSubstr string + }{ + { + name: "off mode - no indicator", + mode: LineNumberOff, + wantSubstr: "showing: 1", + notWantSubstr: "ln:", + }, + { + name: "relative mode - shows ln: rel", + mode: LineNumberRelative, + wantSubstr: "ln: rel", + notWantSubstr: "", + }, + { + name: "hybrid mode - shows ln: hyb", + mode: LineNumberHybrid, + wantSubstr: "ln: hyb", + notWantSubstr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + model := &interactiveModel{ + itemType: "change", + projectPath: "/tmp/test", + table: tbl, + lineNumberMode: tt.mode, + minimalFooter: "showing: 1 | project: /tmp/test | ?: help", + } + view := model.View() + if !strings.Contains(view, tt.wantSubstr) { + t.Errorf( + "Expected view to contain '%s', got: %s", + tt.wantSubstr, + view, + ) + } + if tt.notWantSubstr != "" && strings.Contains(view, tt.notWantSubstr) { + t.Errorf( + "Expected view to NOT contain '%s', got: %s", + tt.notWantSubstr, + view, + ) + } + }) + } +} + +func TestAbs(t *testing.T) { + tests := []struct { + input int + expected int + }{ + {0, 0}, + {5, 5}, + {-5, 5}, + {100, 100}, + {-100, 100}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("abs(%d)", tt.input), func(t *testing.T) { + result := abs(tt.input) + if result != tt.expected { + t.Errorf( + "abs(%d) = %d, want %d", + tt.input, + result, + tt.expected, + ) + } + }) + } +} + +// TestHandleEnter_WithLineNumbers tests that when line numbers are enabled, +// the correct ID is extracted from the row (not the line number). +func TestHandleEnter_WithLineNumbers(t *testing.T) { + tests := []struct { + name string + lineNumberMode LineNumberMode + rows [][]string + columns []table.Column + expectedID string + }{ + { + name: "line numbers off - ID in first column", + lineNumberMode: LineNumberOff, + rows: [][]string{ + {"my-change-id", "My Title", "2"}, + }, + columns: []table.Column{ + {Title: "ID", Width: 35}, + {Title: "Title", Width: 45}, + {Title: "Deltas", Width: 10}, + }, + expectedID: "my-change-id", + }, + { + name: "relative mode - line num in first column, ID in second", + lineNumberMode: LineNumberRelative, + rows: [][]string{ + {"0", "my-change-id", "My Title", "2"}, + }, + columns: []table.Column{ + {Title: "Ln", Width: 3}, + {Title: "ID", Width: 35}, + {Title: "Title", Width: 45}, + {Title: "Deltas", Width: 10}, + }, + expectedID: "my-change-id", + }, + { + name: "hybrid mode - line num in first column, ID in second", + lineNumberMode: LineNumberHybrid, + rows: [][]string{ + {"1", "my-change-id", "My Title", "2"}, + }, + columns: []table.Column{ + {Title: "Ln", Width: 3}, + {Title: "ID", Width: 35}, + {Title: "Title", Width: 45}, + {Title: "Deltas", Width: 10}, + }, + expectedID: "my-change-id", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tableRows := make([]table.Row, len(tt.rows)) + for i, row := range tt.rows { + tableRows[i] = row + } + + model := &interactiveModel{ + stdoutMode: true, + lineNumberMode: tt.lineNumberMode, + table: table.New( + table.WithColumns(tt.columns), + table.WithRows(tableRows), + table.WithFocused(true), + table.WithHeight(10), + ), + } + + model.handleEnter() + + if model.selectedID != tt.expectedID { + t.Errorf( + "selectedID = %q, want %q", + model.selectedID, + tt.expectedID, + ) + } + }) + } +} + +// TestBuildCopyPath_NestedProject tests that nested projects with RootPath +// correctly build paths that include the nested path. +// Note: itemID should be the formatted ID as it would appear in the table row +// (e.g., "[nested/project] my-change" in multi-root mode). +func TestBuildCopyPath_NestedProject(t *testing.T) { + tests := []struct { + name string + itemType string + itemID string // The displayed/formatted ID (what appears in table row) + changesData []ChangeInfo + specsData []SpecInfo + expectedPath string + }{ + { + name: "change in root project", + itemType: itemTypeChange, + itemID: "my-change", + changesData: []ChangeInfo{ + {ID: "my-change", RootPath: ""}, + }, + expectedPath: "spectr/changes/my-change", + }, + { + name: "change in nested project", + itemType: itemTypeChange, + // In multi-root mode, displayed ID has project prefix + itemID: "[nested/project] my-change", + changesData: []ChangeInfo{ + {ID: "my-change", RootPath: "nested/project"}, + }, + expectedPath: "nested/project/spectr/changes/my-change", + }, + { + name: "spec in root project", + itemType: itemTypeSpec, + itemID: "my-spec", + specsData: []SpecInfo{ + {ID: "my-spec", RootPath: ""}, + }, + expectedPath: "spectr/specs/my-spec", + }, + { + name: "spec in nested project", + itemType: itemTypeSpec, + // In multi-root mode, displayed ID has project prefix + itemID: "[apps/frontend] my-spec", + specsData: []SpecInfo{ + {ID: "my-spec", RootPath: "apps/frontend"}, + }, + expectedPath: "apps/frontend/spectr/specs/my-spec", + }, + { + name: "change with dot rootPath treated as root", + itemType: itemTypeChange, + itemID: "my-change", + changesData: []ChangeInfo{ + {ID: "my-change", RootPath: "."}, + }, + expectedPath: "spectr/changes/my-change", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + model := &interactiveModel{ + itemType: tt.itemType, + changesData: tt.changesData, + specsData: tt.specsData, + } + + row := table.Row{tt.itemID} + result := model.buildCopyPath(tt.itemID, row) + + if result != tt.expectedPath { + t.Errorf( + "buildCopyPath() = %q, want %q", + result, + tt.expectedPath, + ) + } + }) + } +} + +// TestHandleArchive_WithLineNumbers tests that handleArchive correctly +// extracts the ID when line numbers are enabled. +func TestHandleArchive_WithLineNumbers(t *testing.T) { + tests := []struct { + name string + lineNumberMode LineNumberMode + rows [][]string + columns []table.Column + itemType string + changesData []ChangeInfo + expectedID string + expectArchiveFlag bool + }{ + { + name: "change mode - line numbers off", + lineNumberMode: LineNumberOff, + rows: [][]string{ + {"my-change-id", "My Title", "2", "3/5"}, + }, + columns: []table.Column{ + {Title: "ID", Width: 35}, + {Title: "Title", Width: 45}, + {Title: "Deltas", Width: 10}, + {Title: "Tasks", Width: 10}, + }, + itemType: itemTypeChange, + changesData: []ChangeInfo{ + {ID: "my-change-id", Title: "My Title", DeltaCount: 2, RootAbsPath: "/tmp/test"}, + }, + expectedID: "my-change-id", + expectArchiveFlag: true, + }, + { + name: "change mode - relative line numbers", + lineNumberMode: LineNumberRelative, + rows: [][]string{ + {"0", "my-change-id", "My Title", "2", "3/5"}, + }, + columns: []table.Column{ + {Title: "Ln", Width: 3}, + {Title: "ID", Width: 35}, + {Title: "Title", Width: 45}, + {Title: "Deltas", Width: 10}, + {Title: "Tasks", Width: 10}, + }, + itemType: itemTypeChange, + changesData: []ChangeInfo{ + {ID: "my-change-id", Title: "My Title", DeltaCount: 2, RootAbsPath: "/tmp/test"}, + }, + expectedID: "my-change-id", + expectArchiveFlag: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tableRows := make([]table.Row, len(tt.rows)) + for i, row := range tt.rows { + tableRows[i] = row + } + + model := &interactiveModel{ + lineNumberMode: tt.lineNumberMode, + itemType: tt.itemType, + changesData: tt.changesData, + table: table.New( + table.WithColumns(tt.columns), + table.WithRows(tableRows), + table.WithFocused(true), + table.WithHeight(10), + ), + } + + model.handleArchive() + + if model.selectedID != tt.expectedID { + t.Errorf( + "selectedID = %q, want %q", + model.selectedID, + tt.expectedID, + ) + } + + if model.archiveRequested != tt.expectArchiveFlag { + t.Errorf( + "archiveRequested = %v, want %v", + model.archiveRequested, + tt.expectArchiveFlag, + ) + } + }) + } +} + +// TestHandlePR_WithLineNumbers tests that handlePR correctly +// extracts the ID when line numbers are enabled. +func TestHandlePR_WithLineNumbers(t *testing.T) { + tests := []struct { + name string + lineNumberMode LineNumberMode + rows [][]string + columns []table.Column + changesData []ChangeInfo + expectedID string + expectPRFlag bool + }{ + { + name: "line numbers off", + lineNumberMode: LineNumberOff, + rows: [][]string{ + {"my-change-id", "My Title", "2", "3/5"}, + }, + columns: []table.Column{ + {Title: "ID", Width: 35}, + {Title: "Title", Width: 45}, + {Title: "Deltas", Width: 10}, + {Title: "Tasks", Width: 10}, + }, + changesData: []ChangeInfo{ + {ID: "my-change-id", Title: "My Title", DeltaCount: 2, RootAbsPath: "/tmp/test"}, + }, + expectedID: "my-change-id", + expectPRFlag: true, + }, + { + name: "relative line numbers", + lineNumberMode: LineNumberRelative, + rows: [][]string{ + {"0", "my-change-id", "My Title", "2", "3/5"}, + }, + columns: []table.Column{ + {Title: "Ln", Width: 3}, + {Title: "ID", Width: 35}, + {Title: "Title", Width: 45}, + {Title: "Deltas", Width: 10}, + {Title: "Tasks", Width: 10}, + }, + changesData: []ChangeInfo{ + {ID: "my-change-id", Title: "My Title", DeltaCount: 2, RootAbsPath: "/tmp/test"}, + }, + expectedID: "my-change-id", + expectPRFlag: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tableRows := make([]table.Row, len(tt.rows)) + for i, row := range tt.rows { + tableRows[i] = row + } + + model := &interactiveModel{ + lineNumberMode: tt.lineNumberMode, + itemType: itemTypeChange, // PR only works in change mode + changesData: tt.changesData, + table: table.New( + table.WithColumns(tt.columns), + table.WithRows(tableRows), + table.WithFocused(true), + table.WithHeight(10), + ), + } + + model.handlePR() + + if model.selectedID != tt.expectedID { + t.Errorf( + "selectedID = %q, want %q", + model.selectedID, + tt.expectedID, + ) + } + + if model.prRequested != tt.expectPRFlag { + t.Errorf( + "prRequested = %v, want %v", + model.prRequested, + tt.expectPRFlag, + ) + } + }) + } +} + +// TestGetEditFilePath_NestedProject tests that getEditFilePath correctly +// builds paths for items in nested projects with RootPath. +func TestGetEditFilePath_NestedProject(t *testing.T) { + // Helper to create a change Item + makeChangeItem := func(id, rootPath string) Item { + return Item{ + Type: ItemTypeChange, + Change: &ChangeInfo{ID: id, RootPath: rootPath}, + } + } + + // Helper to create a spec Item + makeSpecItem := func(id, rootPath string) Item { + return Item{ + Type: ItemTypeSpec, + Spec: &SpecInfo{ID: id, RootPath: rootPath}, + } + } + + tests := []struct { + name string + itemType string + editItemType string // The type passed to getEditFilePath (may differ in unified mode) + itemID string + projectPath string + changesData []ChangeInfo + specsData []SpecInfo + allItems ItemList + expectedPath string + }{ + { + name: "change in root project", + itemType: itemTypeChange, + editItemType: itemTypeChange, + itemID: "my-change", + projectPath: "/mono-repo", + changesData: []ChangeInfo{ + {ID: "my-change", RootPath: ""}, + }, + expectedPath: "/mono-repo/spectr/changes/my-change/proposal.md", + }, + { + name: "change in nested project", + itemType: itemTypeChange, + editItemType: itemTypeChange, + itemID: "my-change", + projectPath: "/mono-repo", + changesData: []ChangeInfo{ + {ID: "my-change", RootPath: "packages/auth"}, + }, + expectedPath: "/mono-repo/packages/auth/spectr/changes/my-change/proposal.md", + }, + { + name: "spec in root project", + itemType: itemTypeSpec, + editItemType: itemTypeSpec, + itemID: "my-spec", + projectPath: "/mono-repo", + specsData: []SpecInfo{ + {ID: "my-spec", RootPath: ""}, + }, + expectedPath: "/mono-repo/spectr/specs/my-spec/spec.md", + }, + { + name: "spec in nested project", + itemType: itemTypeSpec, + editItemType: itemTypeSpec, + itemID: "my-spec", + projectPath: "/mono-repo", + specsData: []SpecInfo{ + {ID: "my-spec", RootPath: "apps/frontend"}, + }, + expectedPath: "/mono-repo/apps/frontend/spectr/specs/my-spec/spec.md", + }, + { + name: "change with dot rootPath treated as root", + itemType: itemTypeChange, + editItemType: itemTypeChange, + itemID: "my-change", + projectPath: "/mono-repo", + changesData: []ChangeInfo{ + {ID: "my-change", RootPath: "."}, + }, + expectedPath: "/mono-repo/spectr/changes/my-change/proposal.md", + }, + // Unified mode (itemTypeAll) tests - uses allItems instead of changesData/specsData + { + name: "unified mode - change in nested project", + itemType: itemTypeAll, + editItemType: itemTypeChange, + itemID: "my-change", + projectPath: "/mono-repo", + allItems: ItemList{ + makeChangeItem("my-change", "packages/api"), + }, + expectedPath: "/mono-repo/packages/api/spectr/changes/my-change/proposal.md", + }, + { + name: "unified mode - spec in nested project", + itemType: itemTypeAll, + editItemType: itemTypeSpec, + itemID: "my-spec", + projectPath: "/mono-repo", + allItems: ItemList{ + makeSpecItem("my-spec", "apps/web"), + }, + expectedPath: "/mono-repo/apps/web/spectr/specs/my-spec/spec.md", + }, + { + name: "unified mode - change in root", + itemType: itemTypeAll, + editItemType: itemTypeChange, + itemID: "my-change", + projectPath: "/mono-repo", + allItems: ItemList{ + makeChangeItem("my-change", ""), + }, + expectedPath: "/mono-repo/spectr/changes/my-change/proposal.md", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + model := &interactiveModel{ + itemType: tt.itemType, + projectPath: tt.projectPath, + changesData: tt.changesData, + specsData: tt.specsData, + allItems: tt.allItems, + } + + result := model.getEditFilePath(tt.itemID, tt.editItemType) + + if result != tt.expectedPath { + t.Errorf( + "getEditFilePath() = %q, want %q", + result, + tt.expectedPath, + ) + } + }) + } +} diff --git a/internal/list/lister.go b/internal/list/lister.go index 1bebd812..fdef9be0 100644 --- a/internal/list/lister.go +++ b/internal/list/lister.go @@ -13,11 +13,27 @@ import ( // Lister handles listing operations for changes and specs type Lister struct { projectPath string + // rootPath is the relative path from cwd to this root (empty for single root) + rootPath string + // absPath is the absolute path to the project root + absPath string } // NewLister creates a new Lister for the given project path func NewLister(projectPath string) *Lister { - return &Lister{projectPath: projectPath} + return &Lister{ + projectPath: projectPath, + absPath: projectPath, + } +} + +// NewListerWithRoot creates a new Lister with root path information +func NewListerWithRoot(projectPath, rootPath string) *Lister { + return &Lister{ + projectPath: projectPath, + rootPath: rootPath, + absPath: projectPath, + } } // ListChanges retrieves information about all active changes @@ -78,10 +94,12 @@ func (l *Lister) ListChanges() ([]ChangeInfo, error) { } changes = append(changes, ChangeInfo{ - ID: id, - Title: title, - DeltaCount: deltaCount, - TaskStatus: taskStatus, + ID: id, + Title: title, + DeltaCount: deltaCount, + TaskStatus: taskStatus, + RootPath: l.rootPath, + RootAbsPath: l.absPath, }) } @@ -131,6 +149,8 @@ func (l *Lister) ListSpecs() ([]SpecInfo, error) { ID: id, Title: title, RequirementCount: reqCount, + RootPath: l.rootPath, + RootAbsPath: l.absPath, }) } @@ -169,10 +189,10 @@ func (l *Lister) ListAll( err, ) } - for _, change := range changes { + for i := range changes { items = append( items, - NewChangeItem(change), + NewChangeItem(&changes[i]), ) } } @@ -242,3 +262,97 @@ func FilterChangesNotOnRef( return unmerged, nil } + +// MultiRootLister aggregates listing results from multiple spectr roots. +type MultiRootLister struct { + listers []*Lister +} + +// NewMultiRootLister creates a lister that aggregates from multiple roots. +func NewMultiRootLister(roots []discovery.SpectrRoot) *MultiRootLister { + listers := make([]*Lister, len(roots)) + for i, root := range roots { + listers[i] = NewListerWithRoot(root.Path, root.RelativeTo) + } + + return &MultiRootLister{listers: listers} +} + +// ListChanges retrieves changes from all roots. +func (m *MultiRootLister) ListChanges() ([]ChangeInfo, error) { + var allChanges []ChangeInfo + + for _, lister := range m.listers { + changes, err := lister.ListChanges() + if err != nil { + return nil, fmt.Errorf( + "failed to list changes from %s: %w", + lister.rootPath, + err, + ) + } + allChanges = append(allChanges, changes...) + } + + // Sort by ID for consistency + sort.Slice(allChanges, func(i, j int) bool { + return allChanges[i].ID < allChanges[j].ID + }) + + return allChanges, nil +} + +// ListSpecs retrieves specs from all roots. +func (m *MultiRootLister) ListSpecs() ([]SpecInfo, error) { + var allSpecs []SpecInfo + + for _, lister := range m.listers { + specs, err := lister.ListSpecs() + if err != nil { + return nil, fmt.Errorf( + "failed to list specs from %s: %w", + lister.rootPath, + err, + ) + } + allSpecs = append(allSpecs, specs...) + } + + // Sort by ID for consistency + sort.Slice(allSpecs, func(i, j int) bool { + return allSpecs[i].ID < allSpecs[j].ID + }) + + return allSpecs, nil +} + +// ListAll retrieves all items from all roots. +func (m *MultiRootLister) ListAll(opts *ListAllOptions) (ItemList, error) { + var items ItemList + + for _, lister := range m.listers { + rootItems, err := lister.ListAll(opts) + if err != nil { + return nil, fmt.Errorf( + "failed to list items from %s: %w", + lister.rootPath, + err, + ) + } + items = append(items, rootItems...) + } + + // Sort by ID for consistency (if requested by options) + if opts == nil || opts.SortByID { + sort.Slice(items, func(i, j int) bool { + return items[i].ID() < items[j].ID() + }) + } + + return items, nil +} + +// HasMultipleRoots returns true if there are multiple roots. +func (m *MultiRootLister) HasMultipleRoots() bool { + return len(m.listers) > 1 +} diff --git a/internal/list/types.go b/internal/list/types.go index c303ec9b..bbd784e3 100644 --- a/internal/list/types.go +++ b/internal/list/types.go @@ -8,6 +8,10 @@ type ChangeInfo struct { Title string `json:"title"` DeltaCount int `json:"deltaCount"` TaskStatus parsers.TaskStatus `json:"taskStatus"` + // RootPath is the relative path to the spectr root from cwd (empty for single root) + RootPath string `json:"rootPath,omitempty"` + // RootAbsPath is the absolute path to the spectr root (for internal use) + RootAbsPath string `json:"-"` } // SpecInfo represents information about a spec @@ -15,6 +19,10 @@ type SpecInfo struct { ID string `json:"id"` Title string `json:"title"` RequirementCount int `json:"requirementCount"` + // RootPath is the relative path to the spectr root from cwd (empty for single root) + RootPath string `json:"rootPath,omitempty"` + // RootAbsPath is the absolute path to the spectr root (for internal use) + RootAbsPath string `json:"-"` } // ItemType represents the type of an item (change or spec) @@ -50,6 +58,38 @@ type Item struct { Spec *SpecInfo `json:"spec,omitempty"` } +// RootPath returns the root path for this item +func (i *Item) RootPath() string { + switch i.Type { + case ItemTypeChange: + if i.Change != nil { + return i.Change.RootPath + } + case ItemTypeSpec: + if i.Spec != nil { + return i.Spec.RootPath + } + } + + return "" +} + +// RootAbsPath returns the absolute root path for this item +func (i *Item) RootAbsPath() string { + switch i.Type { + case ItemTypeChange: + if i.Change != nil { + return i.Change.RootAbsPath + } + case ItemTypeSpec: + if i.Spec != nil { + return i.Spec.RootAbsPath + } + } + + return "" +} + // ID returns the identifier for this item (change ID or spec ID) func (i *Item) ID() string { switch i.Type { @@ -86,10 +126,10 @@ func (i *Item) Title() string { type ItemList []Item // NewChangeItem creates a new Item wrapping a ChangeInfo -func NewChangeItem(change ChangeInfo) Item { +func NewChangeItem(change *ChangeInfo) Item { return Item{ Type: ItemTypeChange, - Change: &change, + Change: change, } } diff --git a/internal/list/types_test.go b/internal/list/types_test.go index ac9cb3e9..087d8ff5 100644 --- a/internal/list/types_test.go +++ b/internal/list/types_test.go @@ -51,7 +51,7 @@ func TestNewChangeItem(t *testing.T) { }, } - item := NewChangeItem(change) + item := NewChangeItem(&change) if item.Type != ItemTypeChange { t.Errorf( @@ -162,13 +162,13 @@ func TestItem_Title_EmptyWhenNil(t *testing.T) { func TestItemList_FilterByType(t *testing.T) { change1 := NewChangeItem( - ChangeInfo{ + &ChangeInfo{ ID: "change1", Title: "Change 1", }, ) change2 := NewChangeItem( - ChangeInfo{ + &ChangeInfo{ ID: "change2", Title: "Change 2", }, @@ -206,13 +206,13 @@ func TestItemList_FilterByType(t *testing.T) { func TestItemList_Changes(t *testing.T) { change1 := NewChangeItem( - ChangeInfo{ + &ChangeInfo{ ID: "change1", Title: "Change 1", }, ) change2 := NewChangeItem( - ChangeInfo{ + &ChangeInfo{ ID: "change2", Title: "Change 2", }, @@ -248,7 +248,7 @@ func TestItemList_Changes(t *testing.T) { func TestItemList_Specs(t *testing.T) { change1 := NewChangeItem( - ChangeInfo{ + &ChangeInfo{ ID: "change1", Title: "Change 1", }, @@ -295,7 +295,7 @@ func TestItem_JSONMarshaling(t *testing.T) { Completed: 2, }, } - item := NewChangeItem(change) + item := NewChangeItem(&change) data, err := json.Marshal(item) if err != nil { diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 09b3e8a3..b0ba0f15 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -61,3 +61,22 @@ func ChoiceStyle() lipgloss.Style { return lipgloss.NewStyle(). PaddingLeft(2) } + +// LineNumberStyle returns the style for line numbers (dimmed, right-aligned). +func LineNumberStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(ColorHelp)). + Align(lipgloss.Right). + Width(3). + MarginRight(1) +} + +// CurrentLineNumberStyle returns the style for the current line number. +func CurrentLineNumberStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(ColorHeader)). + Bold(true). + Align(lipgloss.Right). + Width(3). + MarginRight(1) +} diff --git a/internal/validation/formatters.go b/internal/validation/formatters.go index 3aaabec1..8cd5ec68 100644 --- a/internal/validation/formatters.go +++ b/internal/validation/formatters.go @@ -74,11 +74,12 @@ func ToRelativePath(absPath string) string { // BulkResult represents the result of validating a single item type BulkResult struct { - Name string `json:"name"` - Type string `json:"type"` - Valid bool `json:"valid"` - Report *ValidationReport `json:"report,omitempty"` - Error string `json:"error,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Valid bool `json:"valid"` + Report *ValidationReport `json:"report,omitempty"` + Error string `json:"error,omitempty"` + RootPath string `json:"rootPath,omitempty"` // Relative path to root (multi-root) } // PrintJSONReport prints a single validation report as JSON @@ -293,3 +294,75 @@ func printSummary(p summaryParams) { ) } } + +// PrintBulkHumanResultsMulti prints bulk validation results with optional root prefix. +// +//nolint:revive // flag-parameter: hasMultipleRoots intentionally controls formatting +func PrintBulkHumanResultsMulti(results []BulkResult, hasMultipleRoots bool) { + if !hasMultipleRoots { + PrintBulkHumanResults(results) + + return + } + + passCount := 0 + failCount := 0 + errorCount := 0 + warningCount := 0 + isFirstFailed := true + + for _, result := range results { + // Format name with root prefix + displayName := result.Name + if result.RootPath != "" && result.RootPath != "." { + displayName = fmt.Sprintf("[%s] %s", result.RootPath, result.Name) + } + + if result.Valid { + fmt.Printf( + "✓ %s (%s)\n", + displayName, + result.Type, + ) + passCount++ + } else { + // Add blank line before each failed item (except the first) + if !isFirstFailed { + fmt.Println() + } + isFirstFailed = false + + if result.Error != "" { + fmt.Printf( + "✗ %s (%s): %s\n", + displayName, + result.Type, + result.Error, + ) + errorCount++ + } else { + issueCount := len(result.Report.Issues) + fmt.Printf( + "✗ %s (%s) has %d issue(s):\n", + displayName, + result.Type, + issueCount, + ) + // Count and print issues grouped by file + printGroupedIssues( + result.Report.Issues, &errorCount, &warningCount, + ) + } + failCount++ + } + } + + // Print enhanced summary + printSummary(summaryParams{ + passCount: passCount, + failCount: failCount, + errorCount: errorCount, + warningCount: warningCount, + total: len(results), + }) +} diff --git a/internal/validation/helpers.go b/internal/validation/helpers.go index 73012659..7fe8e678 100644 --- a/internal/validation/helpers.go +++ b/internal/validation/helpers.go @@ -149,18 +149,20 @@ func ValidateSingleItem( if err != nil { return BulkResult{ - Name: item.Name, - Type: item.ItemType, - Valid: false, - Error: err.Error(), + Name: item.Name, + Type: item.ItemType, + Valid: false, + Error: err.Error(), + RootPath: item.RootPath, }, err } return BulkResult{ - Name: item.Name, - Type: item.ItemType, - Valid: report.Valid, - Report: report, + Name: item.Name, + Type: item.ItemType, + Valid: report.Valid, + Report: report, + RootPath: item.RootPath, }, nil } diff --git a/internal/validation/items.go b/internal/validation/items.go index aab003f1..39727813 100644 --- a/internal/validation/items.go +++ b/internal/validation/items.go @@ -13,6 +13,7 @@ type ValidationItem struct { Name string ItemType string // "change" or "spec" Path string + RootPath string // Relative path to spectr root (for multi-root scenarios) } // CreateValidationItems creates validation items from IDs and item type. @@ -117,3 +118,72 @@ func GetSpecItems( basePath, ), nil } + +// GetAllItemsMultiRoot returns all changes and specs from multiple roots. +func GetAllItemsMultiRoot( + roots []discovery.SpectrRoot, +) ([]ValidationItem, error) { + var items []ValidationItem + + for _, root := range roots { + rootItems, err := GetAllItems(root.Path) + if err != nil { + return nil, err + } + + // Add root path to each item + for i := range rootItems { + rootItems[i].RootPath = root.RelativeTo + } + + items = append(items, rootItems...) + } + + return items, nil +} + +// GetChangeItemsMultiRoot returns all changes from multiple roots. +func GetChangeItemsMultiRoot( + roots []discovery.SpectrRoot, +) ([]ValidationItem, error) { + var items []ValidationItem + + for _, root := range roots { + rootItems, err := GetChangeItems(root.Path) + if err != nil { + return nil, err + } + + // Add root path to each item + for i := range rootItems { + rootItems[i].RootPath = root.RelativeTo + } + + items = append(items, rootItems...) + } + + return items, nil +} + +// GetSpecItemsMultiRoot returns all specs from multiple roots. +func GetSpecItemsMultiRoot( + roots []discovery.SpectrRoot, +) ([]ValidationItem, error) { + var items []ValidationItem + + for _, root := range roots { + rootItems, err := GetSpecItems(root.Path) + if err != nil { + return nil, err + } + + // Add root path to each item + for i := range rootItems { + rootItems[i].RootPath = root.RelativeTo + } + + items = append(items, rootItems...) + } + + return items, nil +} diff --git a/internal/view/dashboard.go b/internal/view/dashboard.go index bd998134..068358ee 100644 --- a/internal/view/dashboard.go +++ b/internal/view/dashboard.go @@ -239,3 +239,105 @@ func calculatePercentage( ), ) } + +// aggregateRootData merges data from a single root into the aggregated result. +func aggregateRootData( + aggregated, rootData *DashboardData, + relativeTo string, +) { + // Aggregate metrics + aggregated.Summary.TotalSpecs += rootData.Summary.TotalSpecs + aggregated.Summary.TotalRequirements += rootData.Summary.TotalRequirements + aggregated.Summary.ActiveChanges += rootData.Summary.ActiveChanges + aggregated.Summary.CompletedChanges += rootData.Summary.CompletedChanges + aggregated.Summary.TotalTasks += rootData.Summary.TotalTasks + aggregated.Summary.CompletedTasks += rootData.Summary.CompletedTasks + + // Aggregate active changes with root path + for i := range rootData.ActiveChanges { + rootData.ActiveChanges[i].RootPath = relativeTo + aggregated.ActiveChanges = append(aggregated.ActiveChanges, rootData.ActiveChanges[i]) + } + + // Aggregate completed changes with root path + for i := range rootData.CompletedChanges { + rootData.CompletedChanges[i].RootPath = relativeTo + aggregated.CompletedChanges = append( + aggregated.CompletedChanges, + rootData.CompletedChanges[i], + ) + } + + // Aggregate specs with root path + for i := range rootData.Specs { + rootData.Specs[i].RootPath = relativeTo + aggregated.Specs = append(aggregated.Specs, rootData.Specs[i]) + } +} + +// sortAggregatedData sorts the aggregated dashboard data. +func sortAggregatedData(aggregated *DashboardData) { + sort.Slice(aggregated.ActiveChanges, func(i, j int) bool { + if aggregated.ActiveChanges[i].Progress.Percentage != aggregated.ActiveChanges[j].Progress.Percentage { + return aggregated.ActiveChanges[i].Progress.Percentage < aggregated.ActiveChanges[j].Progress.Percentage + } + + return aggregated.ActiveChanges[i].ID < aggregated.ActiveChanges[j].ID + }) + + sort.Slice(aggregated.CompletedChanges, func(i, j int) bool { + return aggregated.CompletedChanges[i].ID < aggregated.CompletedChanges[j].ID + }) + + sort.Slice(aggregated.Specs, func(i, j int) bool { + if aggregated.Specs[i].RequirementCount != aggregated.Specs[j].RequirementCount { + return aggregated.Specs[i].RequirementCount > aggregated.Specs[j].RequirementCount + } + + return aggregated.Specs[i].ID < aggregated.Specs[j].ID + }) +} + +// CollectDataMultiRoot gathers dashboard information from multiple spectr roots +// and aggregates the results. Each item includes its root path when multiple +// roots are present. +func CollectDataMultiRoot( + roots []discovery.SpectrRoot, +) (*DashboardData, error) { + if len(roots) == 0 { + return &DashboardData{ + Summary: SummaryMetrics{}, + ActiveChanges: make([]ChangeProgress, 0), + CompletedChanges: make([]CompletedChange, 0), + Specs: make([]SpecInfo, 0), + }, nil + } + + // For single root, use existing function (backward compatible) + if len(roots) == 1 { + return CollectData(roots[0].Path) + } + + // Aggregate data from multiple roots + aggregated := &DashboardData{ + Summary: SummaryMetrics{}, + ActiveChanges: make([]ChangeProgress, 0), + CompletedChanges: make([]CompletedChange, 0), + Specs: make([]SpecInfo, 0), + HasMultipleRoots: true, + } + + for _, root := range roots { + rootData, err := CollectData(root.Path) + if err != nil { + // Skip roots that fail to load + continue + } + + aggregateRootData(aggregated, rootData, root.RelativeTo) + } + + sortAggregatedData(aggregated) + + return aggregated, nil +} diff --git a/internal/view/types.go b/internal/view/types.go index f8b87a41..51f1cb0c 100644 --- a/internal/view/types.go +++ b/internal/view/types.go @@ -10,6 +10,7 @@ type DashboardData struct { ActiveChanges []ChangeProgress `json:"activeChanges"` CompletedChanges []CompletedChange `json:"completedChanges"` Specs []SpecInfo `json:"specs"` + HasMultipleRoots bool `json:"hasMultipleRoots,omitempty"` } // SummaryMetrics represents aggregate metrics across the project @@ -30,9 +31,10 @@ type SummaryMetrics struct { // ChangeProgress represents an active change with task completion progress type ChangeProgress struct { - ID string `json:"id"` // Change ID (directory name) - Title string `json:"title"` // Change title from proposal.md - Progress ProgressMetrics `json:"progress"` // Task completion metrics + ID string `json:"id"` // Change ID (directory name) + Title string `json:"title"` // Change title from proposal.md + Progress ProgressMetrics `json:"progress"` // Task completion metrics + RootPath string `json:"rootPath,omitempty"` // Relative path to root (multi-root only) } // ProgressMetrics represents task completion statistics for a change @@ -44,8 +46,9 @@ type ProgressMetrics struct { // CompletedChange represents a change that has all tasks completed type CompletedChange struct { - ID string `json:"id"` // Change ID (directory name) - Title string `json:"title"` // Change title from proposal.md + ID string `json:"id"` // Change ID (directory name) + Title string `json:"title"` // Change title from proposal.md + RootPath string `json:"rootPath,omitempty"` // Relative path to root (multi-root only) } // SpecInfo represents a specification with metadata @@ -56,4 +59,6 @@ type SpecInfo struct { Title string `json:"title"` // Number of requirements in spec RequirementCount int `json:"requirementCount"` + // RootPath is the relative path to the root (multi-root only) + RootPath string `json:"rootPath,omitempty"` } diff --git a/main.go b/main.go index 9e5f22c7..4b54de01 100644 --- a/main.go +++ b/main.go @@ -17,7 +17,10 @@ func main() { cli, kong.Name("spectr"), kong.Description( - "Validatable spec-driven development", + "Validatable spec-driven development\n\n"+ + "Environment Variables:\n"+ + " SPECTR_ROOT Override automatic discovery with explicit spectr root path.\n"+ + " When set, uses only the specified path (skips discovery).", ), kong.UsageOnError(), ) diff --git a/spectr/AGENTS.md b/spectr/AGENTS.md index 290f63fe..b6fbdeef 100644 --- a/spectr/AGENTS.md +++ b/spectr/AGENTS.md @@ -294,6 +294,49 @@ AI Agent: | ModifiedComplete | Error | MODIFIED requirements include full content | | DeltaPresence | Error | Changes MUST have ≥1 delta spec | +## MULTI-REPO DISCOVERY + +Spectr supports mono-repo setups with nested git repositories, each with their +own `spectr/` directory. + +### Discovery Behavior + +- **Automatic discovery**: Spectr walks up from the current working directory to + find all `spectr/` directories, stopping at `.git` boundaries +- **Git isolation**: Each git repository is isolated; discovery stops at the git + root +- **Aggregated results**: Commands like `list`, `validate`, `view` aggregate + results from all discovered roots +- **Root prefix**: In multi-root scenarios, items are prefixed with their + relative path: `[../project] add-feature` + +### SPECTR_ROOT Environment Variable + +Override automatic discovery by setting `SPECTR_ROOT`: + +```bash +# Use explicit spectr root +SPECTR_ROOT=/path/to/project spectr list + +# Relative paths work too +SPECTR_ROOT=../other-project spectr validate --all +``` + +**Behavior:** + +- When set, uses ONLY the specified root (skips automatic discovery) +- Errors if the path doesn't contain a `spectr/` directory + +### Clipboard Copy Paths + +When selecting items in interactive mode (Enter key), Spectr copies the full +path relative to your cwd: + +- Single root: `spectr/changes/add-feature/proposal.md` +- Nested root: `../project/spectr/changes/add-feature/proposal.md` + +This enables direct navigation with `@` file references in AI tools. + ## NOTES - **Validation is strict**: All issues treated as errors (no warnings in strict mode) @@ -308,3 +351,6 @@ AI Agent: - **Multi-platform PRs**: Supports GitHub (gh), GitLab (glab), Gitea/Forgejo (tea), Bitbucket (manual) + +- **Multi-repo support**: Aggregates from all discovered `spectr/` directories + within the git boundary diff --git a/spectr/changes/add-multi-repo-discovery/proposal.md b/spectr/changes/add-multi-repo-discovery/proposal.md new file mode 100644 index 00000000..af616c1d --- /dev/null +++ b/spectr/changes/add-multi-repo-discovery/proposal.md @@ -0,0 +1,38 @@ +# Change: Add Multi-Repo Project Nesting Support + +## Why + +Spectr currently requires running from the exact project root where `spectr/` +lives. In mono-repo setups with nested git repositories (each with their own +`spectr/` directory), users cannot run spectr from subdirectories or aggregate +results across multiple spectr roots. This limits usability for organizations +using mono-repo governance patterns. + +**GitHub Issue**: #363 + +## What Changes + +- **Discovery overhaul**: Walk up from cwd to find all `spectr/` directories, + stopping at `.git` boundaries (each git repo is isolated) +- **Environment override**: Add `SPECTR_ROOT` env var for explicit root + selection (single path, takes precedence over discovery) +- **Aggregated output**: Commands like `list`, `validate`, `view` aggregate + results from all discovered roots, prefixing items with source path + (e.g., `[project] add-feature`) +- **TUI path copying**: Change Enter key behavior to copy path relative to cwd + instead of just the ID, enabling correct proposal application in nested + contexts +- **No inheritance**: Each `spectr/` directory remains completely independent; + no cross-project spec dependencies + +## Impact + +- Affected specs: `cli-interface` (discovery, list, view, TUI behavior) +- Affected code: + - `internal/discovery/` - new multi-root discovery logic + - `cmd/root.go` - env var handling, multi-root iteration + - `cmd/list.go` - aggregated output with prefixes + - `cmd/view.go` - aggregated dashboard + - `cmd/validate.go` - validate across roots + - `internal/tui/` - relative path copying +- **Backward compatible**: Single-root projects work exactly as before diff --git a/spectr/changes/add-multi-repo-discovery/specs/cli-interface/spec.md b/spectr/changes/add-multi-repo-discovery/specs/cli-interface/spec.md new file mode 100644 index 00000000..e80c7fd2 --- /dev/null +++ b/spectr/changes/add-multi-repo-discovery/specs/cli-interface/spec.md @@ -0,0 +1,110 @@ +# Delta Spec: CLI Interface - Multi-Repo Discovery + +## ADDED Requirements + +### Requirement: Multi-Root Discovery + +The system SHALL discover all `spectr/` directories by walking up the directory +tree from the current working directory, stopping at git repository boundaries. + +#### Scenario: Single spectr root in cwd + +- **WHEN** user runs spectr from a directory with `spectr/` as direct child +- **THEN** discover that single root +- **AND** behave identically to current (no prefix in output) + +#### Scenario: Multiple spectr roots in parent chain + +- **WHEN** user runs spectr from `mono/project/src/` +- **AND** `mono/spectr/` and `mono/project/spectr/` both exist +- **THEN** discover both roots +- **AND** aggregate results from all roots + +#### Scenario: Git boundary stops discovery + +- **WHEN** walking up from cwd +- **AND** a `.git` directory is encountered +- **THEN** stop discovery at that git root +- **AND** do not traverse beyond the git repository boundary + +#### Scenario: No spectr root found + +- **WHEN** no `spectr/` directory exists in the path up to git root +- **THEN** return empty list (silent, no error) +- **AND** commands proceed with empty data (current behavior preserved) + +### Requirement: SPECTR_ROOT Environment Variable + +The system SHALL support a `SPECTR_ROOT` environment variable that overrides +automatic discovery with an explicit spectr root path. + +#### Scenario: Env var set to valid path + +- **WHEN** `SPECTR_ROOT` is set to `/path/to/project` +- **AND** `/path/to/project/spectr/` exists +- **THEN** use only that root (skip automatic discovery) + +#### Scenario: Env var set to invalid path + +- **WHEN** `SPECTR_ROOT` is set to a path without `spectr/` directory +- **THEN** emit error "SPECTR_ROOT path does not contain spectr/ directory" +- **AND** exit non-zero + +#### Scenario: Env var not set + +- **WHEN** `SPECTR_ROOT` is not set +- **THEN** use automatic multi-root discovery + +### Requirement: Aggregated Command Output + +The system SHALL aggregate results from all discovered spectr roots in command +output, prefixing items with their source root when multiple roots exist. + +#### Scenario: List with multiple roots + +- **WHEN** `spectr list` runs with multiple discovered roots +- **THEN** display all changes/specs from all roots +- **AND** prefix each item with relative path: `[project] add-feature` + +#### Scenario: List with single root + +- **WHEN** `spectr list` runs with single discovered root +- **THEN** display items without prefix (backward compatible) + +#### Scenario: View dashboard with multiple roots + +- **WHEN** `spectr view` runs with multiple discovered roots +- **THEN** show aggregated summary stats +- **AND** group items by root in display + +#### Scenario: Validate across roots + +- **WHEN** `spectr validate` runs with multiple discovered roots +- **THEN** validate items in all roots +- **AND** prefix validation results with root path + +## MODIFIED Requirements + +### Requirement: Clipboard Copy on Selection + +The system SHALL copy the item path relative to cwd on Enter key press in +interactive list mode. + +#### Scenario: Copy relative path + +- **WHEN** user presses Enter on a selected item in TUI +- **THEN** copy path relative to cwd to clipboard +- **AND** path format: `spectr/changes//proposal.md` for changes +- **AND** path format: `spectr/specs//spec.md` for specs + +#### Scenario: Copy path for nested project + +- **WHEN** user is in `mono/project/src/` +- **AND** selects item from `mono/project/spectr/` +- **THEN** copy `../spectr/changes//proposal.md` + +#### Scenario: Copy path for parent project + +- **WHEN** user is in `mono/project/src/` +- **AND** selects item from `mono/spectr/` +- **THEN** copy `../../spectr/changes//proposal.md` diff --git a/spectr/changes/add-multi-repo-discovery/tasks-1.jsonc b/spectr/changes/add-multi-repo-discovery/tasks-1.jsonc new file mode 100644 index 00000000..da360fdf --- /dev/null +++ b/spectr/changes/add-multi-repo-discovery/tasks-1.jsonc @@ -0,0 +1,40 @@ +// Generated by: spectr accept add-multi-repo-discovery +// Parent change: add-multi-repo-discovery +// Parent task: 1 + +{ + "version": 2, + "tasks": [ + { + "id": "1.1", + "section": "Core Discovery Infrastructure", + "description": "Create `SpectrRoot` type in `internal/discovery/roots.go` with fields:", + "status": "completed" + }, + { + "id": "1.2", + "section": "Core Discovery Infrastructure", + "description": "Implement `FindSpectrRoots(cwd string) ([]SpectrRoot, error)` that walks", + "status": "completed" + }, + { + "id": "1.3", + "section": "Core Discovery Infrastructure", + "description": "Add git boundary detection: check for `.git` directory at each level,", + "status": "completed" + }, + { + "id": "1.4", + "section": "Core Discovery Infrastructure", + "description": "Add `SPECTR_ROOT` env var support: if set, return single root from env", + "status": "completed" + }, + { + "id": "1.5", + "section": "Core Discovery Infrastructure", + "description": "Add unit tests for `FindSpectrRoots` covering: single root, multiple", + "status": "completed" + } + ], + "parent": "1" +} \ No newline at end of file diff --git a/spectr/changes/add-multi-repo-discovery/tasks-2.jsonc b/spectr/changes/add-multi-repo-discovery/tasks-2.jsonc new file mode 100644 index 00000000..ec07a1a4 --- /dev/null +++ b/spectr/changes/add-multi-repo-discovery/tasks-2.jsonc @@ -0,0 +1,40 @@ +// Generated by: spectr accept add-multi-repo-discovery +// Parent change: add-multi-repo-discovery +// Parent task: 2 + +{ + "version": 2, + "tasks": [ + { + "id": "2.1", + "section": "Command Integration", + "description": "Create helper `GetDiscoveredRoots()` in `cmd/` that wraps", + "status": "completed" + }, + { + "id": "2.2", + "section": "Command Integration", + "description": "Update `cmd/root.go` `AfterApply()` to iterate over all discovered", + "status": "completed" + }, + { + "id": "2.3", + "section": "Command Integration", + "description": "Update `cmd/list.go` to aggregate changes/specs from all roots,", + "status": "completed" + }, + { + "id": "2.4", + "section": "Command Integration", + "description": "Update `cmd/view.go` dashboard to show aggregated stats from all", + "status": "completed" + }, + { + "id": "2.5", + "section": "Command Integration", + "description": "Update `cmd/validate.go` to validate items across all discovered", + "status": "completed" + } + ], + "parent": "2" +} \ No newline at end of file diff --git a/spectr/changes/add-multi-repo-discovery/tasks-3.jsonc b/spectr/changes/add-multi-repo-discovery/tasks-3.jsonc new file mode 100644 index 00000000..fc08b031 --- /dev/null +++ b/spectr/changes/add-multi-repo-discovery/tasks-3.jsonc @@ -0,0 +1,34 @@ +// Generated by: spectr accept add-multi-repo-discovery +// Parent change: add-multi-repo-discovery +// Parent task: 3 + +{ + "version": 2, + "tasks": [ + { + "id": "3.1", + "section": "TUI Path Copying", + "description": "Modify `internal/tui/list.go` (or equivalent) to track the full path", + "status": "completed" + }, + { + "id": "3.2", + "section": "TUI Path Copying", + "description": "Change Enter key handler to copy path relative to cwd instead of ID", + "status": "completed" + }, + { + "id": "3.3", + "section": "TUI Path Copying", + "description": "Update clipboard copy to use `filepath.Rel(cwd, itemPath)` for the", + "status": "completed" + }, + { + "id": "3.4", + "section": "TUI Path Copying", + "description": "Ensure path works for both changes (`spectr/changes/\u003cid\u003e/proposal.md`)", + "status": "completed" + } + ], + "parent": "3" +} \ No newline at end of file diff --git a/spectr/changes/add-multi-repo-discovery/tasks-4.jsonc b/spectr/changes/add-multi-repo-discovery/tasks-4.jsonc new file mode 100644 index 00000000..21d4cf07 --- /dev/null +++ b/spectr/changes/add-multi-repo-discovery/tasks-4.jsonc @@ -0,0 +1,34 @@ +// Generated by: spectr accept add-multi-repo-discovery +// Parent change: add-multi-repo-discovery +// Parent task: 4 + +{ + "version": 2, + "tasks": [ + { + "id": "4.1", + "section": "Output Formatting", + "description": "Add `FormatItemWithRoot(root SpectrRoot, id string) string` helper", + "status": "completed" + }, + { + "id": "4.2", + "section": "Output Formatting", + "description": "Update list command table to include a \"Root\" or \"Project\" column", + "status": "completed" + }, + { + "id": "4.3", + "section": "Output Formatting", + "description": "For single-root scenarios, omit the prefix to maintain current", + "status": "completed" + }, + { + "id": "4.4", + "section": "Output Formatting", + "description": "Add tests for output formatting with single and multiple roots", + "status": "completed" + } + ], + "parent": "4" +} \ No newline at end of file diff --git a/spectr/changes/add-multi-repo-discovery/tasks-5.jsonc b/spectr/changes/add-multi-repo-discovery/tasks-5.jsonc new file mode 100644 index 00000000..2e7dcd11 --- /dev/null +++ b/spectr/changes/add-multi-repo-discovery/tasks-5.jsonc @@ -0,0 +1,28 @@ +// Generated by: spectr accept add-multi-repo-discovery +// Parent change: add-multi-repo-discovery +// Parent task: 5 + +{ + "version": 2, + "tasks": [ + { + "id": "5.1", + "section": "Documentation and Polish", + "description": "Update `spectr/AGENTS.md` to document multi-repo discovery behavior", + "status": "completed" + }, + { + "id": "5.2", + "section": "Documentation and Polish", + "description": "Add `SPECTR_ROOT` env var documentation to help text and README", + "status": "completed" + }, + { + "id": "5.3", + "section": "Documentation and Polish", + "description": "Add integration test: create nested git repos with spectr dirs,", + "status": "completed" + } + ], + "parent": "5" +} \ No newline at end of file diff --git a/spectr/changes/add-multi-repo-discovery/tasks-6.jsonc b/spectr/changes/add-multi-repo-discovery/tasks-6.jsonc new file mode 100644 index 00000000..499ed20c --- /dev/null +++ b/spectr/changes/add-multi-repo-discovery/tasks-6.jsonc @@ -0,0 +1,16 @@ +// Generated by: spectr accept add-multi-repo-discovery +// Parent change: add-multi-repo-discovery +// Parent task: 6 + +{ + "version": 2, + "tasks": [ + { + "id": "6.1", + "section": "99.99.99 Final section (after all tasks)", + "description": "Output/return: \u003cpromise\u003e\"This is the last task: COMPLETE\"\u003c/promise\u003e\n", + "status": "completed" + } + ], + "parent": "6" +} \ No newline at end of file diff --git a/spectr/changes/add-multi-repo-discovery/tasks.jsonc b/spectr/changes/add-multi-repo-discovery/tasks.jsonc new file mode 100644 index 00000000..3eef15c7 --- /dev/null +++ b/spectr/changes/add-multi-repo-discovery/tasks.jsonc @@ -0,0 +1,87 @@ +// Spectr Tasks File (JSONC) +// +// This file contains machine-readable task definitions for a Spectr change. +// JSONC format allows comments while maintaining JSON compatibility. +// +// Status Values: +// - "pending" : Task has not been started yet +// - "in_progress" : Task is currently being worked on +// - "completed" : Task has been finished and verified +// +// Status Transitions: +// pending -> in_progress -> completed +// +// Tasks should only move forward through these states. +// Do not skip states or move backward. +// +// Workflow: +// 1. BEFORE starting work on a task, mark it as "in_progress" +// 2. Complete the implementation for the task +// 3. Verify the work is correct and complete +// 4. IMMEDIATELY mark the task as "completed" after verification +// 5. Move to the next task and repeat +// +// IMPORTANT - Update Status Immediately: +// - Update each task's status IMMEDIATELY after it transitions +// - Do NOT batch status updates at the end of all work +// - Do NOT wait until all tasks are done to update statuses +// - This file should reflect accurate progress at any point in time +// - Using a single edit to mark a task completed AND the next task +// in_progress is allowed (this is a single transition, not batching) +// +// Note: This file is auto-generated by 'spectr accept'. Manual edits to +// task status are expected, but structure changes may be overwritten. +// The original tasks.md file is preserved alongside tasks.jsonc to retain +// human-readable formatting, links, and context. +// + +{ + "version": 2, + "tasks": [ + { + "id": "1", + "section": "Core Discovery Infrastructure", + "description": "Core Discovery Infrastructure tasks", + "status": "completed", + "children": "$ref:tasks-1.jsonc" + }, + { + "id": "2", + "section": "Command Integration", + "description": "Command Integration tasks", + "status": "completed", + "children": "$ref:tasks-2.jsonc" + }, + { + "id": "3", + "section": "TUI Path Copying", + "description": "TUI Path Copying tasks", + "status": "completed", + "children": "$ref:tasks-3.jsonc" + }, + { + "id": "4", + "section": "Output Formatting", + "description": "Output Formatting tasks", + "status": "completed", + "children": "$ref:tasks-4.jsonc" + }, + { + "id": "5", + "section": "Documentation and Polish", + "description": "Documentation and Polish tasks", + "status": "completed", + "children": "$ref:tasks-5.jsonc" + }, + { + "id": "6", + "section": "99.99.99 Final section (after all tasks)", + "description": "99.99.99 Final section (after all tasks) tasks", + "status": "completed", + "children": "$ref:tasks-6.jsonc" + } + ], + "includes": [ + "tasks-*.jsonc" + ] +} \ No newline at end of file diff --git a/spectr/changes/add-multi-repo-discovery/tasks.md b/spectr/changes/add-multi-repo-discovery/tasks.md new file mode 100644 index 00000000..ebdfe73a --- /dev/null +++ b/spectr/changes/add-multi-repo-discovery/tasks.md @@ -0,0 +1,54 @@ +# Tasks: Add Multi-Repo Project Nesting Support + +## 1. Core Discovery Infrastructure + +- [ ] 1.1 Create `SpectrRoot` type in `internal/discovery/roots.go` with fields: + `Path` (absolute), `RelativeTo` (relative to cwd), `GitRoot` (parent .git dir) +- [ ] 1.2 Implement `FindSpectrRoots(cwd string) ([]SpectrRoot, error)` that walks + up from cwd, finds all `spectr/` directories, stops at `.git` boundaries +- [ ] 1.3 Add git boundary detection: check for `.git` directory at each level, + don't traverse beyond git root +- [ ] 1.4 Add `SPECTR_ROOT` env var support: if set, return single root from env + var instead of discovery (validate path exists) +- [ ] 1.5 Add unit tests for `FindSpectrRoots` covering: single root, multiple + roots, git boundary stopping, env var override, no roots found + +## 2. Command Integration + +- [ ] 2.1 Create helper `GetDiscoveredRoots()` in `cmd/` that wraps + `FindSpectrRoots` with cwd and returns `[]SpectrRoot` +- [ ] 2.2 Update `cmd/root.go` `AfterApply()` to iterate over all discovered + roots for sync operations +- [ ] 2.3 Update `cmd/list.go` to aggregate changes/specs from all roots, + prefix each item with `[relative-path]` +- [ ] 2.4 Update `cmd/view.go` dashboard to show aggregated stats from all + roots, with per-root breakdown +- [ ] 2.5 Update `cmd/validate.go` to validate items across all discovered + roots + +## 3. TUI Path Copying + +- [ ] 3.1 Modify `internal/tui/list.go` (or equivalent) to track the full path + of each item, not just ID +- [ ] 3.2 Change Enter key handler to copy path relative to cwd instead of ID +- [ ] 3.3 Update clipboard copy to use `filepath.Rel(cwd, itemPath)` for the + copied value +- [ ] 3.4 Ensure path works for both changes (`spectr/changes//proposal.md`) + and specs (`spectr/specs//spec.md`) + +## 4. Output Formatting + +- [ ] 4.1 Add `FormatItemWithRoot(root SpectrRoot, id string) string` helper + that returns `[relative-path] id` format +- [ ] 4.2 Update list command table to include a "Root" or "Project" column + when multiple roots detected +- [ ] 4.3 For single-root scenarios, omit the prefix to maintain current + behavior (backward compatible) +- [ ] 4.4 Add tests for output formatting with single and multiple roots + +## 5. Documentation and Polish + +- [ ] 5.1 Update `spectr/AGENTS.md` to document multi-repo discovery behavior +- [ ] 5.2 Add `SPECTR_ROOT` env var documentation to help text and README +- [ ] 5.3 Add integration test: create nested git repos with spectr dirs, + verify discovery and aggregation diff --git a/spectr/changes/add-relative-line-numbers/proposal.md b/spectr/changes/add-relative-line-numbers/proposal.md new file mode 100644 index 00000000..d7dd4e6f --- /dev/null +++ b/spectr/changes/add-relative-line-numbers/proposal.md @@ -0,0 +1,32 @@ +# Change: Add Relative Line Numbers to List TUI + +## Why + +The list TUI currently supports vim-style count prefixes for navigation (e.g., +`9j` to move down 9 rows). However, users must mentally calculate how many rows +away their target is. Adding relative line numbers - a feature popularized by +Vim's `set relativenumber` - displays each row's distance from the currently +selected row, making count-prefix navigation intuitive and efficient. + +**GitHub Issue**: #364 + +## What Changes + +- **Line number column**: Add an optional leading column displaying line + numbers in the interactive list TUI +- **Relative mode**: By default, show relative distances (1, 2, 3...) from the + cursor row; the cursor row shows absolute position +- **Hybrid mode**: Current row shows absolute number, other rows show relative + distances (matching Vim's `set number relativenumber`) +- **Toggle hotkey**: Add `#` key to cycle through display modes: off (default) + -> relative -> hybrid -> off +- **Footer indicator**: Show current line number mode in footer when active + +## Impact + +- Affected specs: `cli-interface` (interactive list mode requirements) +- Affected code: + - `internal/list/interactive.go` - line number rendering logic + - `internal/tui/styles.go` - line number column styling (dimmed) +- **Backward compatible**: Default behavior unchanged (no line numbers shown); + feature is opt-in via `#` toggle diff --git a/spectr/changes/add-relative-line-numbers/specs/cli-interface/spec.md b/spectr/changes/add-relative-line-numbers/specs/cli-interface/spec.md new file mode 100644 index 00000000..4cb8dd3d --- /dev/null +++ b/spectr/changes/add-relative-line-numbers/specs/cli-interface/spec.md @@ -0,0 +1,54 @@ +# Cli Interface Specification - Delta + +## ADDED Requirements + +### Requirement: Relative Line Numbers in Interactive List + +The system SHALL support optional line number display in interactive list mode, +with relative and hybrid modes to assist vim-style count-prefix navigation. + +#### Scenario: Toggle line number display with # key + +- WHEN user presses `#` in interactive list mode +- THEN the system SHALL cycle through modes: off -> relative -> hybrid -> off +- AND the table display SHALL update immediately to reflect the new mode + +#### Scenario: Relative line number mode display + +- WHEN line number mode is `relative` +- THEN each row SHALL show its distance from the cursor row +- AND the cursor row SHALL display `0` +- AND rows above cursor SHALL show `1`, `2`, `3`... (ascending distance) +- AND rows below cursor SHALL show `1`, `2`, `3`... (ascending distance) + +#### Scenario: Hybrid line number mode display + +- WHEN line number mode is `hybrid` +- THEN the cursor row SHALL display its absolute position (1-indexed) +- AND all other rows SHALL show relative distance from cursor +- AND this matches Vim's `set number relativenumber` behavior + +#### Scenario: Line number column styling + +- WHEN line numbers are displayed +- THEN the line number column SHALL be dimmed (gray) for non-cursor rows +- AND the cursor row's line number SHALL be brighter or bold +- AND the column SHALL be right-aligned with consistent width + +#### Scenario: Footer indicator for line number mode + +- WHEN line number mode is not off +- THEN the footer SHALL include a mode indicator (`ln: rel` or `ln: hyb`) +- AND the indicator SHALL be removed when mode returns to off + +#### Scenario: Default line number mode + +- WHEN interactive list mode starts +- THEN line number mode SHALL default to `off` +- AND no line number column SHALL be displayed +- AND existing behavior SHALL be unchanged + +#### Scenario: Help text includes line number toggle + +- WHEN user presses `?` to view help +- THEN help text SHALL include `#: line numbers` in the key listing diff --git a/spectr/changes/add-relative-line-numbers/tasks.jsonc b/spectr/changes/add-relative-line-numbers/tasks.jsonc new file mode 100644 index 00000000..9751f072 --- /dev/null +++ b/spectr/changes/add-relative-line-numbers/tasks.jsonc @@ -0,0 +1,118 @@ +// Spectr Tasks File (JSONC) +// +// This file contains machine-readable task definitions for a Spectr change. +// JSONC format allows comments while maintaining JSON compatibility. +// +// Status Values: +// - "pending" : Task has not been started yet +// - "in_progress" : Task is currently being worked on +// - "completed" : Task has been finished and verified +// +// Status Transitions: +// pending -> in_progress -> completed +// +// Tasks should only move forward through these states. +// Do not skip states or move backward. +// +// Workflow: +// 1. BEFORE starting work on a task, mark it as "in_progress" +// 2. Complete the implementation for the task +// 3. Verify the work is correct and complete +// 4. IMMEDIATELY mark the task as "completed" after verification +// 5. Move to the next task and repeat +// +// IMPORTANT - Update Status Immediately: +// - Update each task's status IMMEDIATELY after it transitions +// - Do NOT batch status updates at the end of all work +// - Do NOT wait until all tasks are done to update statuses +// - This file should reflect accurate progress at any point in time +// - Using a single edit to mark a task completed AND the next task +// in_progress is allowed (this is a single transition, not batching) +// +// Note: This file is auto-generated by 'spectr accept'. Manual edits to +// task status are expected, but structure changes may be overwritten. +// + +{ + "version": 1, + "tasks": [ + { + "id": "1.1", + "section": "Core Implementation", + "description": "Add LineNumberMode type with constants LineNumberOff, LineNumberRelative, LineNumberHybrid in internal/list/interactive.go", + "status": "completed" + }, + { + "id": "1.2", + "section": "Core Implementation", + "description": "Add lineNumberMode field to interactiveModel struct", + "status": "completed" + }, + { + "id": "1.3", + "section": "Core Implementation", + "description": "Implement cycleLineNumberMode() method and # key handler in Update()", + "status": "completed" + }, + { + "id": "1.4", + "section": "Core Implementation", + "description": "Implement renderLineNumbers() and calculateLineNumber() functions", + "status": "completed" + }, + { + "id": "1.5", + "section": "Core Implementation", + "description": "Update View() method to prepend line number column when mode is not off", + "status": "completed" + }, + { + "id": "1.6", + "section": "Core Implementation", + "description": "Update footer to show ln: rel or ln: hyb when line numbers are active", + "status": "completed" + }, + { + "id": "2.1", + "section": "Styling", + "description": "Add LineNumberStyle() function in internal/tui/styles.go returning dimmed style (gray, right-aligned)", + "status": "completed" + }, + { + "id": "2.2", + "section": "Styling", + "description": "Add CurrentLineNumberStyle() function for the cursor row (brighter, bold)", + "status": "completed" + }, + { + "id": "3.1", + "section": "Testing", + "description": "Add unit tests for LineNumberMode cycling logic", + "status": "completed" + }, + { + "id": "3.2", + "section": "Testing", + "description": "Add unit tests for relative number calculation at different cursor positions", + "status": "completed" + }, + { + "id": "3.3", + "section": "Testing", + "description": "Add unit tests verifying hybrid mode shows absolute for current row", + "status": "completed" + }, + { + "id": "3.4", + "section": "Testing", + "description": "Add unit tests for footer indicator showing correct mode", + "status": "completed" + }, + { + "id": "4.1", + "section": "Documentation", + "description": "Update help text to include #: line numbers in the TUI help for all modes (changes, specs, unified, archive)", + "status": "completed" + } + ] +} diff --git a/spectr/changes/add-relative-line-numbers/tasks.md b/spectr/changes/add-relative-line-numbers/tasks.md new file mode 100644 index 00000000..ff52f8f1 --- /dev/null +++ b/spectr/changes/add-relative-line-numbers/tasks.md @@ -0,0 +1,32 @@ +# Tasks + +## 1. Core Implementation + +- [ ] 1.1. Add `LineNumberMode` type with constants `LineNumberOff`, + `LineNumberRelative`, `LineNumberHybrid` in `internal/list/interactive.go` +- [ ] 1.2. Add `lineNumberMode` field to `interactiveModel` struct +- [ ] 1.3. Implement `renderLineNumbers()` function that returns a styled string + column based on current mode and cursor position +- [ ] 1.4. Update `View()` method to prepend line number column when mode is not + off +- [ ] 1.5. Add `#` key handler in `Update()` to cycle through line number modes +- [ ] 1.6. Update footer to show `ln: rel` or `ln: hyb` when line numbers are + active + +## 2. Styling + +- [ ] 2.1. Add `LineNumberStyle()` function in `internal/tui/styles.go` returning + dimmed style (gray, right-aligned) +- [ ] 2.2. Add `CurrentLineNumberStyle()` function for the cursor row (brighter, + maybe bold) + +## 3. Testing + +- [ ] 3.1. Add unit tests for `LineNumberMode` cycling logic +- [ ] 3.2. Add unit tests for relative number calculation at different cursor + positions (top, middle, bottom of list) +- [ ] 3.3. Add unit tests verifying hybrid mode shows absolute for current row + +## 4. Documentation + +- [ ] 4.1. Update help text to include `#: line numbers` in the TUI help