Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
```
Expand Down
5 changes: 1 addition & 4 deletions internal/discover/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
})

Expand Down
10 changes: 3 additions & 7 deletions internal/discover/discover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
36 changes: 36 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"github.com/alecthomas/kong"
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -225,6 +259,8 @@ func main() {
}
}

sortProjects(projects, cli.Sort, cli.SortDirection, iconMapper)

if cli.JSON {
type projectJSON struct {
Path string `json:"path"`
Expand Down
193 changes: 193 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import (
"strings"
"testing"
"time"

"github.com/josephschmitt/pj/internal/discover"
"github.com/josephschmitt/pj/internal/icons"
)

var (
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
}
}
Loading