diff --git a/README.md b/README.md index 858c01c..0dd5db3 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,8 @@ pj --version | `--icon-map MARKER:ICON` | | Override icon mapping | | `--color-map MARKER:COLOR` | | Override icon color | | `--format FORMAT` | `-f` | Custom output format (see [Format Placeholders](#format-placeholders)) | +| `--sort VALUE` | | Sort order: `alpha`, `priority`, `label` (default: `priority`) | +| `--sort-direction VALUE` | | Sort direction: `asc`, `desc` (default: `desc`) | | `--no-cache` | | Skip cache, force fresh search | | `--clear-cache` | | Clear cache and exit | | `--verbose` | `-v` | Enable debug output | @@ -244,6 +246,12 @@ pj --labels display # Combine icons, labels, and color pj --icons -la +# Sort alphabetically by path +pj --sort alpha + +# Sort by label name +pj --sort label --sort-direction asc + # Verbose output for debugging pj -v ``` diff --git a/internal/discover/discover.go b/internal/discover/discover.go index 7df8523..b6be70c 100644 --- a/internal/discover/discover.go +++ b/internal/discover/discover.go @@ -100,11 +100,8 @@ func (d *Discoverer) Discover() ([]Project, error) { } } - // Sort by priority (higher first), then by path + // Sort by path for deterministic output; presentation sorting is handled by the caller sort.Slice(projects, func(i, j int) bool { - if projects[i].Priority != projects[j].Priority { - return projects[i].Priority > projects[j].Priority - } return projects[i].Path < projects[j].Path }) diff --git a/internal/discover/discover_test.go b/internal/discover/discover_test.go index 1df0cb2..42af80b 100644 --- a/internal/discover/discover_test.go +++ b/internal/discover/discover_test.go @@ -234,15 +234,11 @@ func TestDiscover(t *testing.T) { } } - // Verify results are sorted by priority then path + // Verify results are sorted by path if len(projects) > 1 { for i := 0; i < len(projects)-1; i++ { - if projects[i].Priority < projects[i+1].Priority { - t.Errorf("Projects not sorted by priority: project[%d].Priority = %d < project[%d].Priority = %d", - i, projects[i].Priority, i+1, projects[i+1].Priority) - } - if projects[i].Priority == projects[i+1].Priority && projects[i].Path > projects[i+1].Path { - t.Errorf("Projects with same priority not sorted by path: %q > %q", + if projects[i].Path > projects[i+1].Path { + t.Errorf("Projects not sorted by path: %q > %q", projects[i].Path, projects[i+1].Path) } } diff --git a/main.go b/main.go index ab2d1c3..8396088 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "github.com/alecthomas/kong" @@ -56,6 +57,8 @@ type CLI struct { Shorten bool `short:"s" help:"Shorten home directory to ~ in output paths"` NoCache bool `help:"Skip cache, force fresh search"` ClearCache bool `help:"Clear cache and exit"` + Sort string `help:"Sort order: alpha, priority, label (default: priority)" default:"priority" enum:"alpha,priority,label"` + SortDirection string `help:"Sort direction: asc, desc (default: desc)" default:"desc" enum:"asc,desc" name:"sort-direction"` JSON bool `short:"j" help:"Output results in JSON format"` Verbose bool `short:"v" help:"Enable debug output"` Version bool `short:"V" help:"Show version"` @@ -119,6 +122,37 @@ func formatOutput(format string, values map[string]string) string { return result } +func sortProjects(projects []discover.Project, sortBy, direction string, mapper *icons.Mapper) { + desc := direction == "desc" + sort.SliceStable(projects, func(i, j int) bool { + switch sortBy { + case "priority": + if projects[i].Priority != projects[j].Priority { + if desc { + return projects[i].Priority > projects[j].Priority + } + return projects[i].Priority < projects[j].Priority + } + return projects[i].Path < projects[j].Path + case "label": + labelI := mapper.GetLabel(projects[i].Marker) + labelJ := mapper.GetLabel(projects[j].Marker) + if labelI != labelJ { + if desc { + return labelI > labelJ + } + return labelI < labelJ + } + return projects[i].Path < projects[j].Path + default: // "alpha" + if desc { + return projects[i].Path > projects[j].Path + } + return projects[i].Path < projects[j].Path + } + }) +} + func main() { var cli CLI ctx := kong.Parse(&cli, @@ -225,6 +259,8 @@ func main() { } } + sortProjects(projects, cli.Sort, cli.SortDirection, iconMapper) + if cli.JSON { type projectJSON struct { Path string `json:"path"` diff --git a/main_test.go b/main_test.go index fae7f72..f215b80 100644 --- a/main_test.go +++ b/main_test.go @@ -9,6 +9,9 @@ import ( "strings" "testing" "time" + + "github.com/josephschmitt/pj/internal/discover" + "github.com/josephschmitt/pj/internal/icons" ) var ( @@ -410,6 +413,7 @@ func TestCLI_PrioritySorting(t *testing.T) { createTestProject(t, tmpDir, "high-priority", "go.mod") createTestProject(t, tmpDir, "low-priority", ".git/") + // Default sort is priority descending stdout, stderr, err := runPJ(t, "-p", tmpDir, "--no-cache") if err != nil { t.Fatalf("pj failed: %v\nStderr: %s", err, stderr) @@ -1642,3 +1646,192 @@ func TestCLI_JSONOutputFormat(t *testing.T) { t.Error("JSON output should contain 'projects' key") } } + +func TestSortProjects(t *testing.T) { + mapper := icons.NewMapper( + map[string]string{}, + map[string]string{}, + map[string]string{".git": "git", "go.mod": "go", "Cargo.toml": "rust"}, + map[string]string{}, + ) + + projects := []discover.Project{ + {Path: "/z/project", Marker: "go.mod", Priority: 10}, + {Path: "/a/project", Marker: ".git", Priority: 1}, + {Path: "/m/project", Marker: "Cargo.toml", Priority: 10}, + } + + tests := []struct { + name string + sortBy string + direction string + expected []string + }{ + { + name: "alpha asc", + sortBy: "alpha", + direction: "asc", + expected: []string{"/a/project", "/m/project", "/z/project"}, + }, + { + name: "alpha desc", + sortBy: "alpha", + direction: "desc", + expected: []string{"/z/project", "/m/project", "/a/project"}, + }, + { + name: "priority asc", + sortBy: "priority", + direction: "asc", + expected: []string{"/a/project", "/m/project", "/z/project"}, + }, + { + name: "priority desc", + sortBy: "priority", + direction: "desc", + expected: []string{"/m/project", "/z/project", "/a/project"}, + }, + { + name: "label asc", + sortBy: "label", + direction: "asc", + expected: []string{"/a/project", "/z/project", "/m/project"}, + }, + { + name: "label desc", + sortBy: "label", + direction: "desc", + expected: []string{"/m/project", "/z/project", "/a/project"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := make([]discover.Project, len(projects)) + copy(p, projects) + sortProjects(p, tt.sortBy, tt.direction, mapper) + for i, proj := range p { + if proj.Path != tt.expected[i] { + t.Errorf("position %d: got %q, want %q", i, proj.Path, tt.expected[i]) + } + } + }) + } +} + +func TestCLI_SortAlpha(t *testing.T) { + tmpDir := t.TempDir() + + createTestProject(t, tmpDir, "z-project", "go.mod") + createTestProject(t, tmpDir, "a-project", ".git/") + + stdout, stderr, err := runPJ(t, "-p", tmpDir, "--no-cache", "--sort", "alpha", "--sort-direction", "asc") + if err != nil { + t.Fatalf("pj failed: %v\nStderr: %s", err, stderr) + } + + lines := strings.Split(strings.TrimSpace(stdout), "\n") + if len(lines) != 2 { + t.Fatalf("Expected 2 projects, got %d", len(lines)) + } + + if !strings.Contains(lines[0], "a-project") { + t.Errorf("Alpha asc: first line should be a-project, got: %s", lines[0]) + } + if !strings.Contains(lines[1], "z-project") { + t.Errorf("Alpha asc: second line should be z-project, got: %s", lines[1]) + } +} + +func TestCLI_SortPriorityAsc(t *testing.T) { + tmpDir := t.TempDir() + + createTestProject(t, tmpDir, "go-project", "go.mod") + createTestProject(t, tmpDir, "git-project", ".git/") + + stdout, stderr, err := runPJ(t, "-p", tmpDir, "--no-cache", "--sort", "priority", "--sort-direction", "asc") + if err != nil { + t.Fatalf("pj failed: %v\nStderr: %s", err, stderr) + } + + lines := strings.Split(strings.TrimSpace(stdout), "\n") + if len(lines) != 2 { + t.Fatalf("Expected 2 projects, got %d", len(lines)) + } + + // Ascending: low priority (.git=1) first + if !strings.Contains(lines[0], "git-project") { + t.Errorf("Priority asc: first line should be git-project, got: %s", lines[0]) + } + if !strings.Contains(lines[1], "go-project") { + t.Errorf("Priority asc: second line should be go-project, got: %s", lines[1]) + } +} + +func TestCLI_SortLabel(t *testing.T) { + tmpDir := t.TempDir() + + // Labels: go.mod="go", .git="git", Cargo.toml="rust" + createTestProject(t, tmpDir, "rust-project", "Cargo.toml") + createTestProject(t, tmpDir, "git-project", ".git/") + createTestProject(t, tmpDir, "go-project", "go.mod") + + stdout, stderr, err := runPJ(t, "-p", tmpDir, "--no-cache", "--sort", "label") + if err != nil { + t.Fatalf("pj failed: %v\nStderr: %s", err, stderr) + } + + lines := strings.Split(strings.TrimSpace(stdout), "\n") + if len(lines) != 3 { + t.Fatalf("Expected 3 projects, got %d", len(lines)) + } + + // Default direction is desc, so label desc: rust > go > git + if !strings.Contains(lines[0], "rust-project") { + t.Errorf("Label desc: first line should be rust-project, got: %s", lines[0]) + } + if !strings.Contains(lines[1], "go-project") { + t.Errorf("Label desc: second line should be go-project, got: %s", lines[1]) + } + if !strings.Contains(lines[2], "git-project") { + t.Errorf("Label desc: third line should be git-project, got: %s", lines[2]) + } +} + +func TestCLI_SortAlphaDesc(t *testing.T) { + tmpDir := t.TempDir() + + createTestProject(t, tmpDir, "a-project", ".git/") + createTestProject(t, tmpDir, "z-project", "go.mod") + + stdout, stderr, err := runPJ(t, "-p", tmpDir, "--no-cache", "--sort", "alpha", "--sort-direction", "desc") + if err != nil { + t.Fatalf("pj failed: %v\nStderr: %s", err, stderr) + } + + lines := strings.Split(strings.TrimSpace(stdout), "\n") + if len(lines) != 2 { + t.Fatalf("Expected 2 projects, got %d", len(lines)) + } + + if !strings.Contains(lines[0], "z-project") { + t.Errorf("Alpha desc: first line should be z-project, got: %s", lines[0]) + } + if !strings.Contains(lines[1], "a-project") { + t.Errorf("Alpha desc: second line should be a-project, got: %s", lines[1]) + } +} + +func TestCLI_SortInvalidValue(t *testing.T) { + _, _, err := runPJ(t, "--sort", "invalid") + if err == nil { + t.Error("Invalid sort value should produce an error") + } +} + +func TestCLI_SortDirectionInvalidValue(t *testing.T) { + _, _, err := runPJ(t, "--sort-direction", "invalid") + if err == nil { + t.Error("Invalid sort-direction value should produce an error") + } +}