From 8fc70ab17efe232696f0412274ebf2ca429044a3 Mon Sep 17 00:00:00 2001 From: kostyay Date: Mon, 12 Jan 2026 23:07:20 +0100 Subject: [PATCH 1/2] feat: add purge command to delete closed tickets Add kt purge command that permanently deletes all closed tickets with interactive confirmation and reference validation. Features: - Lists all closed tickets before deletion - Interactive confirmation prompt (y/N) - Validates no open tickets reference closed ones (parent/deps/links) - Blocks purge in JSON mode (requires interactive) - Comprehensive test suite with 11 test cases Co-Authored-By: Claude Sonnet 4.5 --- internal/cmd/purge.go | 145 +++++++++++++++ internal/cmd/purge_test.go | 370 +++++++++++++++++++++++++++++++++++++ 2 files changed, 515 insertions(+) create mode 100644 internal/cmd/purge.go create mode 100644 internal/cmd/purge_test.go diff --git a/internal/cmd/purge.go b/internal/cmd/purge.go new file mode 100644 index 0000000..3eadf93 --- /dev/null +++ b/internal/cmd/purge.go @@ -0,0 +1,145 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/kostyay/kticket/internal/ticket" + "github.com/spf13/cobra" +) + +var purgeCmd = &cobra.Command{ + Use: "purge", + Short: "Delete all closed tickets", + Long: "Permanently delete all closed ticket files. Validates that no open tickets reference them.", + RunE: runPurge, +} + +func init() { + rootCmd.AddCommand(purgeCmd) +} + +type purgeResult struct { + Deleted int `json:"deleted"` + Errors []string `json:"errors,omitempty"` +} + +func runPurge(cmd *cobra.Command, args []string) error { + // Get all tickets + allTickets, err := Store.List() + if err != nil { + return fmt.Errorf("list tickets: %w", err) + } + + // Filter closed tickets + var closedTickets []*ticket.Ticket + for _, t := range allTickets { + if t.Status == ticket.StatusClosed { + closedTickets = append(closedTickets, t) + } + } + + // Early exit if nothing to purge + if len(closedTickets) == 0 { + if IsJSON() { + return PrintJSON(purgeResult{Deleted: 0}) + } + fmt.Println("No closed tickets to purge") + return nil + } + + // Validate references + if err := validatePurge(allTickets, closedTickets); err != nil { + return err + } + + // Interactive confirmation (skip in JSON mode) + if IsJSON() { + return fmt.Errorf("refusing to purge in JSON mode (interactive confirmation required)") + } + + confirmed, err := promptConfirmation(closedTickets) + if err != nil { + return fmt.Errorf("prompt: %w", err) + } + + if !confirmed { + fmt.Println("Purge cancelled") + return nil + } + + // Delete files + deleted := 0 + for _, t := range closedTickets { + path := filepath.Join(Store.Dir, t.ID+".md") + if err := os.Remove(path); err != nil { + return fmt.Errorf("delete %s: %w", t.ID, err) + } + deleted++ + } + + if IsJSON() { + return PrintJSON(purgeResult{Deleted: deleted}) + } + + fmt.Printf("Purged %d tickets\n", deleted) + return nil +} + +// validatePurge checks if any non-closed tickets reference closed tickets +func validatePurge(allTickets, closedTickets []*ticket.Ticket) error { + // Build set of closed ticket IDs for fast lookup + closedSet := make(map[string]bool) + for _, t := range closedTickets { + closedSet[t.ID] = true + } + + // Check all non-closed tickets for references + for _, t := range allTickets { + if t.Status == ticket.StatusClosed { + continue + } + + // Check parent reference + if t.Parent != "" && closedSet[t.Parent] { + return fmt.Errorf("cannot purge %s: ticket %s has it as parent", t.Parent, t.ID) + } + + // Check dependencies + for _, dep := range t.Deps { + if closedSet[dep] { + return fmt.Errorf("cannot purge %s: ticket %s depends on it", dep, t.ID) + } + } + + // Check links + for _, link := range t.Links { + if closedSet[link] { + return fmt.Errorf("cannot purge %s: ticket %s links to it", link, t.ID) + } + } + } + + return nil +} + +// promptConfirmation shows tickets and asks for user confirmation +func promptConfirmation(tickets []*ticket.Ticket) (bool, error) { + fmt.Printf("Found %d closed tickets:\n", len(tickets)) + for _, t := range tickets { + fmt.Printf(" %s: %s\n", t.ID, t.Title) + } + fmt.Printf("\nPurge %d tickets? [y/N] ", len(tickets)) + + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + return false, err + } + + response = strings.ToLower(strings.TrimSpace(response)) + return response == "y" || response == "yes", nil +} diff --git a/internal/cmd/purge_test.go b/internal/cmd/purge_test.go new file mode 100644 index 0000000..f06bee3 --- /dev/null +++ b/internal/cmd/purge_test.go @@ -0,0 +1,370 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/kostyay/kticket/internal/ticket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPurgeBasic(t *testing.T) { + defer setupTestEnv(t)() + + // Create 3 tickets, close 2 + open := mkTicket(t, "kt-001", "Open Task", ticket.StatusOpen) + closed1 := mkTicket(t, "kt-002", "Closed Task 1", ticket.StatusClosed) + closed2 := mkTicket(t, "kt-003", "Closed Task 2", ticket.StatusClosed) + + // Mock stdin with "y" + oldStdin := os.Stdin + defer func() { os.Stdin = oldStdin }() + + r, w, _ := os.Pipe() + os.Stdin = r + go func() { + defer w.Close() + w.Write([]byte("y\n")) + }() + + err := runPurge(nil, nil) + require.NoError(t, err) + + // Verify only 1 file remains (open ticket) + files, _ := filepath.Glob(filepath.Join(Store.Dir, "*.md")) + assert.Len(t, files, 1) + + // Verify open ticket still exists + _, err = Store.Get(open.ID) + assert.NoError(t, err) + + // Verify closed tickets are deleted + _, err = Store.Get(closed1.ID) + assert.Error(t, err) + _, err = Store.Get(closed2.ID) + assert.Error(t, err) +} + +func TestPurgeNoClosedTickets(t *testing.T) { + defer setupTestEnv(t)() + + mkTicket(t, "kt-001", "Open Task 1", ticket.StatusOpen) + mkTicket(t, "kt-002", "Open Task 2", ticket.StatusOpen) + + err := runPurge(nil, nil) + require.NoError(t, err) + + // Verify all tickets still exist + files, _ := filepath.Glob(filepath.Join(Store.Dir, "*.md")) + assert.Len(t, files, 2) +} + +func TestPurgeBlockedByParent(t *testing.T) { + defer setupTestEnv(t)() + + parent := mkTicket(t, "kt-parent", "Parent Epic", ticket.StatusClosed) + child := mkTicket(t, "kt-child", "Child Task", ticket.StatusOpen) + + // Set parent reference + child.Parent = parent.ID + require.NoError(t, Store.Save(child)) + + // Try to purge - should be blocked + err := runPurge(nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot purge") + assert.Contains(t, err.Error(), parent.ID) + assert.Contains(t, err.Error(), "parent") + + // Verify parent still exists + _, err = Store.Get(parent.ID) + assert.NoError(t, err) +} + +func TestPurgeBlockedByDep(t *testing.T) { + defer setupTestEnv(t)() + + dep := mkTicket(t, "kt-dep", "Dependency", ticket.StatusClosed) + task := mkTicket(t, "kt-task", "Task", ticket.StatusOpen) + + // Set dependency + task.Deps = []string{dep.ID} + require.NoError(t, Store.Save(task)) + + // Try to purge - should be blocked + err := runPurge(nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot purge") + assert.Contains(t, err.Error(), dep.ID) + assert.Contains(t, err.Error(), "depends") + + // Verify dep still exists + _, err = Store.Get(dep.ID) + assert.NoError(t, err) +} + +func TestPurgeBlockedByLink(t *testing.T) { + defer setupTestEnv(t)() + + linked := mkTicket(t, "kt-linked", "Linked", ticket.StatusClosed) + task := mkTicket(t, "kt-task", "Task", ticket.StatusOpen) + + // Set link + task.Links = []string{linked.ID} + require.NoError(t, Store.Save(task)) + + // Try to purge - should be blocked + err := runPurge(nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot purge") + assert.Contains(t, err.Error(), linked.ID) + assert.Contains(t, err.Error(), "links") + + // Verify linked ticket still exists + _, err = Store.Get(linked.ID) + assert.NoError(t, err) +} + +func TestPurgeUserCancels(t *testing.T) { + defer setupTestEnv(t)() + + closed := mkTicket(t, "kt-001", "Closed Task", ticket.StatusClosed) + + // Mock stdin with "n" + oldStdin := os.Stdin + defer func() { os.Stdin = oldStdin }() + + r, w, _ := os.Pipe() + os.Stdin = r + go func() { + defer w.Close() + w.Write([]byte("n\n")) + }() + + err := runPurge(nil, nil) + require.NoError(t, err) + + // Verify ticket still exists + _, err = Store.Get(closed.ID) + assert.NoError(t, err) + + files, _ := filepath.Glob(filepath.Join(Store.Dir, "*.md")) + assert.Len(t, files, 1) +} + +func TestPurgeJSONMode(t *testing.T) { + defer setupTestEnv(t)() + jsonFlag = true + defer func() { jsonFlag = false }() + + mkTicket(t, "kt-001", "Closed Task", ticket.StatusClosed) + + // JSON mode should error (requires interactive confirmation) + err := runPurge(nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "refusing to purge in JSON mode") + + // Verify ticket still exists + files, _ := filepath.Glob(filepath.Join(Store.Dir, "*.md")) + assert.Len(t, files, 1) +} + +func TestPurgeJSONModeNoClosed(t *testing.T) { + defer setupTestEnv(t)() + jsonFlag = true + defer func() { jsonFlag = false }() + + mkTicket(t, "kt-001", "Open Task", ticket.StatusOpen) + + // Should succeed with no closed tickets + err := runPurge(nil, nil) + require.NoError(t, err) +} + +func TestValidatePurge(t *testing.T) { + defer setupTestEnv(t)() + + // Create tickets + closed1 := mkTicket(t, "kt-closed1", "Closed 1", ticket.StatusClosed) + closed2 := mkTicket(t, "kt-closed2", "Closed 2", ticket.StatusClosed) + open1 := mkTicket(t, "kt-open1", "Open 1", ticket.StatusOpen) + open2 := mkTicket(t, "kt-open2", "Open 2", ticket.StatusOpen) + + allTickets := []*ticket.Ticket{closed1, closed2, open1, open2} + closedTickets := []*ticket.Ticket{closed1, closed2} + + // Should pass - no references + err := validatePurge(allTickets, closedTickets) + assert.NoError(t, err) + + // Add parent reference - should fail + open1.Parent = closed1.ID + err = validatePurge(allTickets, closedTickets) + assert.Error(t, err) + assert.Contains(t, err.Error(), closed1.ID) + assert.Contains(t, err.Error(), "parent") + + // Remove parent, add dep - should fail + open1.Parent = "" + open1.Deps = []string{closed2.ID} + err = validatePurge(allTickets, closedTickets) + assert.Error(t, err) + assert.Contains(t, err.Error(), closed2.ID) + assert.Contains(t, err.Error(), "depends") + + // Remove dep, add link - should fail + open1.Deps = nil + open2.Links = []string{closed1.ID} + err = validatePurge(allTickets, closedTickets) + assert.Error(t, err) + assert.Contains(t, err.Error(), closed1.ID) + assert.Contains(t, err.Error(), "links") +} + +func TestPromptConfirmation(t *testing.T) { + closed1 := &ticket.Ticket{ + ID: "kt-001", + Status: ticket.StatusClosed, + Title: "First Closed", + } + closed2 := &ticket.Ticket{ + ID: "kt-002", + Status: ticket.StatusClosed, + Title: "Second Closed", + } + + tickets := []*ticket.Ticket{closed1, closed2} + + // Test "y" input + oldStdin := os.Stdin + defer func() { os.Stdin = oldStdin }() + + r, w, _ := os.Pipe() + os.Stdin = r + go func() { + defer w.Close() + w.Write([]byte("y\n")) + }() + + confirmed, err := promptConfirmation(tickets) + require.NoError(t, err) + assert.True(t, confirmed) + + // Test "yes" input + r, w, _ = os.Pipe() + os.Stdin = r + go func() { + defer w.Close() + w.Write([]byte("yes\n")) + }() + + confirmed, err = promptConfirmation(tickets) + require.NoError(t, err) + assert.True(t, confirmed) + + // Test "n" input + r, w, _ = os.Pipe() + os.Stdin = r + go func() { + defer w.Close() + w.Write([]byte("n\n")) + }() + + confirmed, err = promptConfirmation(tickets) + require.NoError(t, err) + assert.False(t, confirmed) + + // Test "no" input + r, w, _ = os.Pipe() + os.Stdin = r + go func() { + defer w.Close() + w.Write([]byte("no\n")) + }() + + confirmed, err = promptConfirmation(tickets) + require.NoError(t, err) + assert.False(t, confirmed) + + // Test empty input (default to no) + r, w, _ = os.Pipe() + os.Stdin = r + go func() { + defer w.Close() + w.Write([]byte("\n")) + }() + + confirmed, err = promptConfirmation(tickets) + require.NoError(t, err) + assert.False(t, confirmed) +} + +func TestPurgeMultipleReferences(t *testing.T) { + defer setupTestEnv(t)() + + // Create closed ticket referenced by multiple open tickets + closed := mkTicket(t, "kt-closed", "Closed", ticket.StatusClosed) + open1 := mkTicket(t, "kt-open1", "Open 1", ticket.StatusOpen) + open2 := mkTicket(t, "kt-open2", "Open 2", ticket.StatusOpen) + + // Multiple references + open1.Deps = []string{closed.ID} + open2.Links = []string{closed.ID} + require.NoError(t, Store.Save(open1)) + require.NoError(t, Store.Save(open2)) + + // Should be blocked + err := runPurge(nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot purge") +} + +func TestPurgeOnlyClosedReferences(t *testing.T) { + defer setupTestEnv(t)() + + // Closed tickets can reference each other + closed1 := mkTicket(t, "kt-closed1", "Closed 1", ticket.StatusClosed) + closed2 := mkTicket(t, "kt-closed2", "Closed 2", ticket.StatusClosed) + + closed1.Deps = []string{closed2.ID} + closed2.Links = []string{closed1.ID} + require.NoError(t, Store.Save(closed1)) + require.NoError(t, Store.Save(closed2)) + + // Mock stdin with "y" + oldStdin := os.Stdin + defer func() { os.Stdin = oldStdin }() + + r, w, _ := os.Pipe() + os.Stdin = r + go func() { + defer w.Close() + w.Write([]byte("y\n")) + }() + + // Should succeed - closed tickets can reference each other + err := runPurge(nil, nil) + require.NoError(t, err) + + // Both should be deleted + files, _ := filepath.Glob(filepath.Join(Store.Dir, "*.md")) + assert.Len(t, files, 0) +} + +func TestPurgeInProgressReferences(t *testing.T) { + defer setupTestEnv(t)() + + // In-progress ticket references closed ticket + closed := mkTicket(t, "kt-closed", "Closed", ticket.StatusClosed) + inProgress := mkTicket(t, "kt-progress", "In Progress", ticket.StatusInProgress) + + inProgress.Deps = []string{closed.ID} + require.NoError(t, Store.Save(inProgress)) + + // Should be blocked + err := runPurge(nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot purge") +} From 2d05853ac647f9e771287a5f01496a8451fd0727 Mon Sep 17 00:00:00 2001 From: kostyay Date: Mon, 12 Jan 2026 23:10:39 +0100 Subject: [PATCH 2/2] refactor: simplify purge command code - Remove unnecessary comments (code is self-documenting) - Extract mockStdin helper to reduce test boilerplate - Convert TestPromptConfirmation to table-driven test - Use len(closedTickets) instead of tracking deleted counter Co-Authored-By: Claude Sonnet 4.5 --- internal/cmd/purge.go | 21 +---- internal/cmd/purge_test.go | 167 ++++++++----------------------------- 2 files changed, 36 insertions(+), 152 deletions(-) diff --git a/internal/cmd/purge.go b/internal/cmd/purge.go index 3eadf93..2cbe483 100644 --- a/internal/cmd/purge.go +++ b/internal/cmd/purge.go @@ -28,13 +28,11 @@ type purgeResult struct { } func runPurge(cmd *cobra.Command, args []string) error { - // Get all tickets allTickets, err := Store.List() if err != nil { return fmt.Errorf("list tickets: %w", err) } - // Filter closed tickets var closedTickets []*ticket.Ticket for _, t := range allTickets { if t.Status == ticket.StatusClosed { @@ -42,7 +40,6 @@ func runPurge(cmd *cobra.Command, args []string) error { } } - // Early exit if nothing to purge if len(closedTickets) == 0 { if IsJSON() { return PrintJSON(purgeResult{Deleted: 0}) @@ -51,12 +48,10 @@ func runPurge(cmd *cobra.Command, args []string) error { return nil } - // Validate references if err := validatePurge(allTickets, closedTickets); err != nil { return err } - // Interactive confirmation (skip in JSON mode) if IsJSON() { return fmt.Errorf("refusing to purge in JSON mode (interactive confirmation required)") } @@ -71,51 +66,38 @@ func runPurge(cmd *cobra.Command, args []string) error { return nil } - // Delete files - deleted := 0 for _, t := range closedTickets { path := filepath.Join(Store.Dir, t.ID+".md") if err := os.Remove(path); err != nil { return fmt.Errorf("delete %s: %w", t.ID, err) } - deleted++ } - if IsJSON() { - return PrintJSON(purgeResult{Deleted: deleted}) - } - - fmt.Printf("Purged %d tickets\n", deleted) + fmt.Printf("Purged %d tickets\n", len(closedTickets)) return nil } -// validatePurge checks if any non-closed tickets reference closed tickets func validatePurge(allTickets, closedTickets []*ticket.Ticket) error { - // Build set of closed ticket IDs for fast lookup closedSet := make(map[string]bool) for _, t := range closedTickets { closedSet[t.ID] = true } - // Check all non-closed tickets for references for _, t := range allTickets { if t.Status == ticket.StatusClosed { continue } - // Check parent reference if t.Parent != "" && closedSet[t.Parent] { return fmt.Errorf("cannot purge %s: ticket %s has it as parent", t.Parent, t.ID) } - // Check dependencies for _, dep := range t.Deps { if closedSet[dep] { return fmt.Errorf("cannot purge %s: ticket %s depends on it", dep, t.ID) } } - // Check links for _, link := range t.Links { if closedSet[link] { return fmt.Errorf("cannot purge %s: ticket %s links to it", link, t.ID) @@ -126,7 +108,6 @@ func validatePurge(allTickets, closedTickets []*ticket.Ticket) error { return nil } -// promptConfirmation shows tickets and asks for user confirmation func promptConfirmation(tickets []*ticket.Ticket) (bool, error) { fmt.Printf("Found %d closed tickets:\n", len(tickets)) for _, t := range tickets { diff --git a/internal/cmd/purge_test.go b/internal/cmd/purge_test.go index f06bee3..a3c528e 100644 --- a/internal/cmd/purge_test.go +++ b/internal/cmd/purge_test.go @@ -13,34 +13,21 @@ import ( func TestPurgeBasic(t *testing.T) { defer setupTestEnv(t)() - // Create 3 tickets, close 2 open := mkTicket(t, "kt-001", "Open Task", ticket.StatusOpen) closed1 := mkTicket(t, "kt-002", "Closed Task 1", ticket.StatusClosed) closed2 := mkTicket(t, "kt-003", "Closed Task 2", ticket.StatusClosed) - // Mock stdin with "y" - oldStdin := os.Stdin - defer func() { os.Stdin = oldStdin }() - - r, w, _ := os.Pipe() - os.Stdin = r - go func() { - defer w.Close() - w.Write([]byte("y\n")) - }() + mockStdin(t, "y\n") err := runPurge(nil, nil) require.NoError(t, err) - // Verify only 1 file remains (open ticket) files, _ := filepath.Glob(filepath.Join(Store.Dir, "*.md")) assert.Len(t, files, 1) - // Verify open ticket still exists _, err = Store.Get(open.ID) assert.NoError(t, err) - // Verify closed tickets are deleted _, err = Store.Get(closed1.ID) assert.Error(t, err) _, err = Store.Get(closed2.ID) @@ -67,18 +54,15 @@ func TestPurgeBlockedByParent(t *testing.T) { parent := mkTicket(t, "kt-parent", "Parent Epic", ticket.StatusClosed) child := mkTicket(t, "kt-child", "Child Task", ticket.StatusOpen) - // Set parent reference child.Parent = parent.ID require.NoError(t, Store.Save(child)) - // Try to purge - should be blocked err := runPurge(nil, nil) require.Error(t, err) assert.Contains(t, err.Error(), "cannot purge") assert.Contains(t, err.Error(), parent.ID) assert.Contains(t, err.Error(), "parent") - // Verify parent still exists _, err = Store.Get(parent.ID) assert.NoError(t, err) } @@ -89,18 +73,15 @@ func TestPurgeBlockedByDep(t *testing.T) { dep := mkTicket(t, "kt-dep", "Dependency", ticket.StatusClosed) task := mkTicket(t, "kt-task", "Task", ticket.StatusOpen) - // Set dependency task.Deps = []string{dep.ID} require.NoError(t, Store.Save(task)) - // Try to purge - should be blocked err := runPurge(nil, nil) require.Error(t, err) assert.Contains(t, err.Error(), "cannot purge") assert.Contains(t, err.Error(), dep.ID) assert.Contains(t, err.Error(), "depends") - // Verify dep still exists _, err = Store.Get(dep.ID) assert.NoError(t, err) } @@ -111,18 +92,15 @@ func TestPurgeBlockedByLink(t *testing.T) { linked := mkTicket(t, "kt-linked", "Linked", ticket.StatusClosed) task := mkTicket(t, "kt-task", "Task", ticket.StatusOpen) - // Set link task.Links = []string{linked.ID} require.NoError(t, Store.Save(task)) - // Try to purge - should be blocked err := runPurge(nil, nil) require.Error(t, err) assert.Contains(t, err.Error(), "cannot purge") assert.Contains(t, err.Error(), linked.ID) assert.Contains(t, err.Error(), "links") - // Verify linked ticket still exists _, err = Store.Get(linked.ID) assert.NoError(t, err) } @@ -132,21 +110,11 @@ func TestPurgeUserCancels(t *testing.T) { closed := mkTicket(t, "kt-001", "Closed Task", ticket.StatusClosed) - // Mock stdin with "n" - oldStdin := os.Stdin - defer func() { os.Stdin = oldStdin }() - - r, w, _ := os.Pipe() - os.Stdin = r - go func() { - defer w.Close() - w.Write([]byte("n\n")) - }() + mockStdin(t, "n\n") err := runPurge(nil, nil) require.NoError(t, err) - // Verify ticket still exists _, err = Store.Get(closed.ID) assert.NoError(t, err) @@ -161,12 +129,10 @@ func TestPurgeJSONMode(t *testing.T) { mkTicket(t, "kt-001", "Closed Task", ticket.StatusClosed) - // JSON mode should error (requires interactive confirmation) err := runPurge(nil, nil) require.Error(t, err) assert.Contains(t, err.Error(), "refusing to purge in JSON mode") - // Verify ticket still exists files, _ := filepath.Glob(filepath.Join(Store.Dir, "*.md")) assert.Len(t, files, 1) } @@ -178,7 +144,6 @@ func TestPurgeJSONModeNoClosed(t *testing.T) { mkTicket(t, "kt-001", "Open Task", ticket.StatusOpen) - // Should succeed with no closed tickets err := runPurge(nil, nil) require.NoError(t, err) } @@ -186,7 +151,6 @@ func TestPurgeJSONModeNoClosed(t *testing.T) { func TestValidatePurge(t *testing.T) { defer setupTestEnv(t)() - // Create tickets closed1 := mkTicket(t, "kt-closed1", "Closed 1", ticket.StatusClosed) closed2 := mkTicket(t, "kt-closed2", "Closed 2", ticket.StatusClosed) open1 := mkTicket(t, "kt-open1", "Open 1", ticket.StatusOpen) @@ -195,18 +159,15 @@ func TestValidatePurge(t *testing.T) { allTickets := []*ticket.Ticket{closed1, closed2, open1, open2} closedTickets := []*ticket.Ticket{closed1, closed2} - // Should pass - no references err := validatePurge(allTickets, closedTickets) assert.NoError(t, err) - // Add parent reference - should fail open1.Parent = closed1.ID err = validatePurge(allTickets, closedTickets) assert.Error(t, err) assert.Contains(t, err.Error(), closed1.ID) assert.Contains(t, err.Error(), "parent") - // Remove parent, add dep - should fail open1.Parent = "" open1.Deps = []string{closed2.ID} err = validatePurge(allTickets, closedTickets) @@ -214,7 +175,6 @@ func TestValidatePurge(t *testing.T) { assert.Contains(t, err.Error(), closed2.ID) assert.Contains(t, err.Error(), "depends") - // Remove dep, add link - should fail open1.Deps = nil open2.Links = []string{closed1.ID} err = validatePurge(allTickets, closedTickets) @@ -224,98 +184,42 @@ func TestValidatePurge(t *testing.T) { } func TestPromptConfirmation(t *testing.T) { - closed1 := &ticket.Ticket{ - ID: "kt-001", - Status: ticket.StatusClosed, - Title: "First Closed", + tickets := []*ticket.Ticket{ + {ID: "kt-001", Status: ticket.StatusClosed, Title: "First Closed"}, + {ID: "kt-002", Status: ticket.StatusClosed, Title: "Second Closed"}, } - closed2 := &ticket.Ticket{ - ID: "kt-002", - Status: ticket.StatusClosed, - Title: "Second Closed", - } - - tickets := []*ticket.Ticket{closed1, closed2} - - // Test "y" input - oldStdin := os.Stdin - defer func() { os.Stdin = oldStdin }() - - r, w, _ := os.Pipe() - os.Stdin = r - go func() { - defer w.Close() - w.Write([]byte("y\n")) - }() - - confirmed, err := promptConfirmation(tickets) - require.NoError(t, err) - assert.True(t, confirmed) - - // Test "yes" input - r, w, _ = os.Pipe() - os.Stdin = r - go func() { - defer w.Close() - w.Write([]byte("yes\n")) - }() - - confirmed, err = promptConfirmation(tickets) - require.NoError(t, err) - assert.True(t, confirmed) - - // Test "n" input - r, w, _ = os.Pipe() - os.Stdin = r - go func() { - defer w.Close() - w.Write([]byte("n\n")) - }() - - confirmed, err = promptConfirmation(tickets) - require.NoError(t, err) - assert.False(t, confirmed) - - // Test "no" input - r, w, _ = os.Pipe() - os.Stdin = r - go func() { - defer w.Close() - w.Write([]byte("no\n")) - }() - - confirmed, err = promptConfirmation(tickets) - require.NoError(t, err) - assert.False(t, confirmed) - // Test empty input (default to no) - r, w, _ = os.Pipe() - os.Stdin = r - go func() { - defer w.Close() - w.Write([]byte("\n")) - }() + tests := []struct { + input string + want bool + }{ + {"y\n", true}, + {"yes\n", true}, + {"n\n", false}, + {"no\n", false}, + {"\n", false}, + } - confirmed, err = promptConfirmation(tickets) - require.NoError(t, err) - assert.False(t, confirmed) + for _, tc := range tests { + mockStdin(t, tc.input) + confirmed, err := promptConfirmation(tickets) + require.NoError(t, err) + assert.Equal(t, tc.want, confirmed) + } } func TestPurgeMultipleReferences(t *testing.T) { defer setupTestEnv(t)() - // Create closed ticket referenced by multiple open tickets closed := mkTicket(t, "kt-closed", "Closed", ticket.StatusClosed) open1 := mkTicket(t, "kt-open1", "Open 1", ticket.StatusOpen) open2 := mkTicket(t, "kt-open2", "Open 2", ticket.StatusOpen) - // Multiple references open1.Deps = []string{closed.ID} open2.Links = []string{closed.ID} require.NoError(t, Store.Save(open1)) require.NoError(t, Store.Save(open2)) - // Should be blocked err := runPurge(nil, nil) require.Error(t, err) assert.Contains(t, err.Error(), "cannot purge") @@ -324,7 +228,6 @@ func TestPurgeMultipleReferences(t *testing.T) { func TestPurgeOnlyClosedReferences(t *testing.T) { defer setupTestEnv(t)() - // Closed tickets can reference each other closed1 := mkTicket(t, "kt-closed1", "Closed 1", ticket.StatusClosed) closed2 := mkTicket(t, "kt-closed2", "Closed 2", ticket.StatusClosed) @@ -333,22 +236,11 @@ func TestPurgeOnlyClosedReferences(t *testing.T) { require.NoError(t, Store.Save(closed1)) require.NoError(t, Store.Save(closed2)) - // Mock stdin with "y" - oldStdin := os.Stdin - defer func() { os.Stdin = oldStdin }() + mockStdin(t, "y\n") - r, w, _ := os.Pipe() - os.Stdin = r - go func() { - defer w.Close() - w.Write([]byte("y\n")) - }() - - // Should succeed - closed tickets can reference each other err := runPurge(nil, nil) require.NoError(t, err) - // Both should be deleted files, _ := filepath.Glob(filepath.Join(Store.Dir, "*.md")) assert.Len(t, files, 0) } @@ -356,15 +248,26 @@ func TestPurgeOnlyClosedReferences(t *testing.T) { func TestPurgeInProgressReferences(t *testing.T) { defer setupTestEnv(t)() - // In-progress ticket references closed ticket closed := mkTicket(t, "kt-closed", "Closed", ticket.StatusClosed) inProgress := mkTicket(t, "kt-progress", "In Progress", ticket.StatusInProgress) inProgress.Deps = []string{closed.ID} require.NoError(t, Store.Save(inProgress)) - // Should be blocked err := runPurge(nil, nil) require.Error(t, err) assert.Contains(t, err.Error(), "cannot purge") } + +func mockStdin(t *testing.T, input string) { + t.Helper() + oldStdin := os.Stdin + t.Cleanup(func() { os.Stdin = oldStdin }) + + r, w, _ := os.Pipe() + os.Stdin = r + go func() { + defer w.Close() + w.Write([]byte(input)) + }() +}