diff --git a/cmd/helpers.go b/cmd/helpers.go index dfe77c6..6898b1a 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -220,12 +220,12 @@ func formatFieldCounts(fieldCounts map[string]int) string { return "-" } - // Sort field names for consistent output + // Sort field names for consistent output (case-insensitive) fields := make([]string, 0, len(fieldCounts)) for field := range fieldCounts { fields = append(fields, field) } - sort.Strings(fields) + sort.Slice(fields, func(i, j int) bool { return lessFold(fields[i], fields[j]) }) // Build formatted string parts := make([]string, 0, len(fields)) diff --git a/cmd/list.go b/cmd/list.go index 3671306..b56775b 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -159,9 +159,9 @@ func runList(cmd *cobra.Command, args []string) error { } // Standard list mode (existing behavior) - // Sort by service name + // Sort by service name (case-insensitive) sort.Slice(metadata, func(i, j int) bool { - return metadata[i].Service < metadata[j].Service + return lessFold(metadata[i].Service, metadata[j].Service) }) // Output in requested format @@ -382,7 +382,7 @@ func groupCredentialsByProject(metadata []vault.CredentialMetadata) map[string][ for cred := range credSet { creds = append(creds, cred) } - sort.Strings(creds) + sort.Slice(creds, func(i, j int) bool { return lessFold(creds[i], creds[j]) }) result[project] = creds } @@ -415,7 +415,7 @@ func outputByProjectTable(projects map[string][]string) error { for project := range projects { projectNames = append(projectNames, project) } - sort.Strings(projectNames) + sort.Slice(projectNames, func(i, j int) bool { return lessFold(projectNames[i], projectNames[j]) }) // Display each project group for _, project := range projectNames { @@ -469,7 +469,7 @@ func outputByProjectSimple(projects map[string][]string) error { for project := range projects { projectNames = append(projectNames, project) } - sort.Strings(projectNames) + sort.Slice(projectNames, func(i, j int) bool { return lessFold(projectNames[i], projectNames[j]) }) // Output each project on one line for _, project := range projectNames { @@ -479,3 +479,14 @@ func outputByProjectSimple(projects map[string][]string) error { return nil } + +// lessFold reports whether a should sort before b, compared case-insensitively. +// It falls back to a case-sensitive comparison as a tie-break so that strings +// differing only in case keep a stable, deterministic order. +func lessFold(a, b string) bool { + la, lb := strings.ToLower(a), strings.ToLower(b) + if la != lb { + return la < lb + } + return a < b +} diff --git a/cmd/tui/components/detail.go b/cmd/tui/components/detail.go index fcd5467..3b2418c 100644 --- a/cmd/tui/components/detail.go +++ b/cmd/tui/components/detail.go @@ -562,8 +562,8 @@ func FormatUsageLocations(cred *vault.Credential) string { for field, count := range record.FieldAccess { fieldParts = append(fieldParts, fmt.Sprintf("%s:%d", field, count)) } - // Sort field names for consistent display - sort.Strings(fieldParts) + // Sort field names for consistent display (case-insensitive) + sort.Slice(fieldParts, func(i, j int) bool { return lessFold(fieldParts[i], fieldParts[j]) }) b.WriteString(strings.Join(fieldParts, ", ")) b.WriteString(fmt.Sprintf(")%s", textColor())) } diff --git a/cmd/tui/components/sidebar.go b/cmd/tui/components/sidebar.go index b5cf8ab..2ea3f85 100644 --- a/cmd/tui/components/sidebar.go +++ b/cmd/tui/components/sidebar.go @@ -2,6 +2,7 @@ package components import ( "sort" + "strings" "github.com/arimxyer/pass-cli/cmd/tui/models" "github.com/arimxyer/pass-cli/cmd/tui/styles" @@ -10,6 +11,17 @@ import ( "github.com/rivo/tview" ) +// lessFold reports whether a should sort before b, compared case-insensitively. +// It falls back to a case-sensitive comparison as a tie-break so that strings +// differing only in case keep a stable, deterministic order. +func lessFold(a, b string) bool { + la, lb := strings.ToLower(a), strings.ToLower(b) + if la != lb { + return la < lb + } + return a < b +} + // NodeReference identifies the type and value of a tree node. // Used to distinguish categories from credentials without relying on tree position. type NodeReference struct { @@ -96,7 +108,10 @@ func (s *Sidebar) Refresh() { for category := range groups { categories = append(categories, category) } - sort.Strings(categories) // Sort categories alphabetically + // Sort categories alphabetically (case-insensitive) + sort.Slice(categories, func(i, j int) bool { + return lessFold(categories[i], categories[j]) + }) // Build category nodes with credential children for _, category := range categories { @@ -110,10 +125,10 @@ func (s *Sidebar) Refresh() { SetReference(NodeReference{Kind: "category", Value: category}). SetExpanded(false) // Collapsed by default - // Sort credentials within category for deterministic ordering + // Sort credentials within category alphabetically (case-insensitive) credList := groups[category] sort.Slice(credList, func(i, j int) bool { - return credList[i].Service < credList[j].Service + return lessFold(credList[i].Service, credList[j].Service) }) // Add credential nodes from sorted list diff --git a/cmd/tui/components/table.go b/cmd/tui/components/table.go index 36b0d76..93bd763 100644 --- a/cmd/tui/components/table.go +++ b/cmd/tui/components/table.go @@ -2,6 +2,7 @@ package components import ( "fmt" + "sort" "time" "github.com/arimxyer/pass-cli/cmd/tui/models" @@ -83,6 +84,12 @@ func (ct *CredentialTable) Refresh() { searchState := ct.appState.GetSearchState() ct.filteredCreds = ct.filterBySearch(categoryFiltered, searchState) + // Sort by service alphabetically (case-insensitive) so the table matches + // the sidebar's ordering; GetCredentials returns map-iteration order. + sort.Slice(ct.filteredCreds, func(i, j int) bool { + return lessFold(ct.filteredCreds[i].Service, ct.filteredCreds[j].Service) + }) + // Get current row count (excluding header) currentRowCount := ct.GetRowCount() - 1 newRowCount := len(ct.filteredCreds) diff --git a/cmd/tui/components/table_test.go b/cmd/tui/components/table_test.go index f85b2f5..1e95f06 100644 --- a/cmd/tui/components/table_test.go +++ b/cmd/tui/components/table_test.go @@ -282,10 +282,13 @@ func TestCredentialTablePopulateRows(t *testing.T) { // Setup credentials with different last accessed times now := time.Now() + // Includes a lowercase "azure" entry: under a case-sensitive sort it would + // land last (ASCII 'a' > 'G'), but case-insensitively it belongs after AWS. mockCreds := []vault.CredentialMetadata{ {Service: "AWS", Username: "admin", CreatedAt: now, LastAccessed: now.Add(-30 * time.Second)}, {Service: "GitHub", Username: "user", CreatedAt: now, LastAccessed: now.Add(-5 * time.Minute)}, {Service: "Database", Username: "dbuser", CreatedAt: now, LastAccessed: time.Time{}}, + {Service: "azure", Username: "svc", CreatedAt: now, LastAccessed: now.Add(-1 * time.Minute)}, } mockVault.SetCredentials(mockCreds) _ = state.LoadCredentials() @@ -293,27 +296,29 @@ func TestCredentialTablePopulateRows(t *testing.T) { table := NewCredentialTable(state) table.Refresh() - // Verify row 1 (AWS) - row1Col0 := table.GetCell(1, 0) - if row1Col0.Text != "AWS" { - t.Errorf("Expected 'AWS', got '%s'", row1Col0.Text) + // Rows are sorted alphabetically by service (case-insensitive), so the + // expected order is AWS, azure, Database, GitHub regardless of insertion order. + wantOrder := []string{"AWS", "azure", "Database", "GitHub"} + for i, want := range wantOrder { + got := table.GetCell(i+1, 0).Text + if got != want { + t.Errorf("Expected row %d service '%s', got '%s'", i+1, want, got) + } } // Verify credential reference stored in cell - if row1Col0.GetReference() == nil { + if table.GetCell(1, 0).GetReference() == nil { t.Error("Expected credential reference in cell, got nil") } - // Verify row 2 (GitHub) last used formatted - row2Col2 := table.GetCell(2, 2) - if row2Col2.Text == "" { + // GitHub (row 4) last used formatted + if got := table.GetCell(4, 2).Text; got == "" { t.Error("Expected formatted last used time") } - // Verify row 3 (Database) shows "Never" for zero time - row3Col2 := table.GetCell(3, 2) - if row3Col2.Text != "Never" { - t.Errorf("Expected 'Never' for zero LastAccessed, got '%s'", row3Col2.Text) + // Database (row 3) shows "Never" for zero time + if got := table.GetCell(3, 2).Text; got != "Never" { + t.Errorf("Expected 'Never' for zero LastAccessed, got '%s'", got) } } diff --git a/cmd/tui/models/state.go b/cmd/tui/models/state.go index 8f71b6e..46066b5 100644 --- a/cmd/tui/models/state.go +++ b/cmd/tui/models/state.go @@ -6,6 +6,7 @@ package models import ( "fmt" "sort" + "strings" "sync" "github.com/arimxyer/pass-cli/internal/vault" @@ -13,6 +14,17 @@ import ( "github.com/rivo/tview" ) +// lessFold reports whether a should sort before b, compared case-insensitively. +// It falls back to a case-sensitive comparison as a tie-break so that strings +// differing only in case keep a stable, deterministic order. +func lessFold(a, b string) bool { + la, lb := strings.ToLower(a), strings.ToLower(b) + if la != lb { + return la < lb + } + return a < b +} + // VaultService interface defines the vault operations needed by AppState. // This interface enables testing with mock implementations. // T020d: Updated AddCredential signature to accept []byte password @@ -567,12 +579,12 @@ func (s *AppState) updateCategories() { } } - // Convert map to sorted slice + // Convert map to sorted slice (case-insensitive) categories := make([]string, 0, len(categoryMap)) for category := range categoryMap { categories = append(categories, category) } - sort.Strings(categories) + sort.Slice(categories, func(i, j int) bool { return lessFold(categories[i], categories[j]) }) s.categories = categories } diff --git a/cmd/vault_backup_preview.go b/cmd/vault_backup_preview.go index eef7131..f188dcf 100644 --- a/cmd/vault_backup_preview.go +++ b/cmd/vault_backup_preview.go @@ -107,12 +107,12 @@ func runVaultBackupPreview(cmd *cobra.Command, args []string) error { fmt.Printf("Found %d credential(s) in backup:\n\n", credCount) - // Sort service names alphabetically + // Sort service names alphabetically (case-insensitive) services := make([]string, 0, credCount) for service := range vaultData.Credentials { services = append(services, service) } - sort.Strings(services) + sort.Slice(services, func(i, j int) bool { return lessFold(services[i], services[j]) }) if previewVerbose { // Verbose: show table with more details