diff options
| author | Paul Buetow <paul@buetow.org> | 2025-06-28 00:00:15 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-06-28 00:00:15 +0300 |
| commit | 0e065b3b0f5e935fc769be2f1e84779fa9897e99 (patch) | |
| tree | e72775ab2fba73100955ac04b2c66e2d567fe7b6 | |
| parent | e527f6084f4a3f592d06c25e34e08cc3769706a8 (diff) | |
fix: resolve test failures and improve code quality
- Fix file handle leak in SetDebugLog by tracking and closing previous files
- Add stderr capture to all taskwarrior commands for better error messages
- Fix timezone issues in date handling tests by normalizing to UTC
- Change Update method to pointer receiver for consistency
- Update all test type assertions to handle pointer receivers correctly
- Remove unused imports and variables
All tests now pass successfully.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
| -rw-r--r-- | internal/task/operations_test.go | 133 | ||||
| -rw-r--r-- | internal/task/task.go | 71 | ||||
| -rw-r--r-- | internal/ui/handlers.go | 437 | ||||
| -rw-r--r-- | internal/ui/helpers.go | 191 | ||||
| -rw-r--r-- | internal/ui/helpers_test.go | 363 | ||||
| -rw-r--r-- | internal/ui/keyhandlers.go | 493 | ||||
| -rw-r--r-- | internal/ui/table.go | 723 | ||||
| -rw-r--r-- | internal/ui/table_test.go | 149 | ||||
| -rw-r--r-- | internal/ui/theme.go | 3 |
9 files changed, 1807 insertions, 756 deletions
diff --git a/internal/task/operations_test.go b/internal/task/operations_test.go new file mode 100644 index 0000000..7abd4bd --- /dev/null +++ b/internal/task/operations_test.go @@ -0,0 +1,133 @@ +package task + +import ( + "strings" + "testing" +) + +func TestModifyTask(t *testing.T) { + tests := []struct { + name string + id int + args []string + wantErr bool + errMsg string + }{ + { + name: "valid ID", + id: 1, + args: []string{"status:pending"}, + wantErr: false, + }, + { + name: "zero ID", + id: 0, + args: []string{"status:pending"}, + wantErr: true, + errMsg: "invalid task ID: 0", + }, + { + name: "negative ID", + id: -1, + args: []string{"status:pending"}, + wantErr: true, + errMsg: "invalid task ID: -1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := modifyTask(tt.id, tt.args...) + + // We can't test actual taskwarrior commands without it installed + // So we just test the validation + if tt.wantErr { + if err == nil { + t.Errorf("modifyTask() error = nil, wantErr %v", tt.wantErr) + } else if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("modifyTask() error = %v, want error containing %v", err, tt.errMsg) + } + } + }) + } +} + +func TestSimpleTaskCommand(t *testing.T) { + tests := []struct { + name string + id int + command string + wantErr bool + errMsg string + }{ + { + name: "valid ID", + id: 1, + command: "done", + wantErr: false, + }, + { + name: "zero ID", + id: 0, + command: "done", + wantErr: true, + errMsg: "invalid task ID: 0", + }, + { + name: "negative ID", + id: -5, + command: "done", + wantErr: true, + errMsg: "invalid task ID: -5", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := simpleTaskCommand(tt.id, tt.command) + + // We can't test actual taskwarrior commands without it installed + // So we just test the validation + if tt.wantErr { + if err == nil { + t.Errorf("simpleTaskCommand() error = nil, wantErr %v", tt.wantErr) + } else if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("simpleTaskCommand() error = %v, want error containing %v", err, tt.errMsg) + } + } + }) + } +} + +func TestTaskOperationsValidation(t *testing.T) { + // Test that all task operations validate IDs + invalidID := -1 + + operations := []struct { + name string + fn func() error + }{ + {"SetStatus", func() error { return SetStatus(invalidID, "pending") }}, + {"Start", func() error { return Start(invalidID) }}, + {"Stop", func() error { return Stop(invalidID) }}, + {"Done", func() error { return Done(invalidID) }}, + {"Delete", func() error { return Delete(invalidID) }}, + {"SetPriority", func() error { return SetPriority(invalidID, "H") }}, + {"SetRecurrence", func() error { return SetRecurrence(invalidID, "daily") }}, + {"SetDueDate", func() error { return SetDueDate(invalidID, "tomorrow") }}, + {"SetDescription", func() error { return SetDescription(invalidID, "test") }}, + {"Annotate", func() error { return Annotate(invalidID, "note") }}, + {"Denotate", func() error { return Denotate(invalidID, "note") }}, + } + + for _, op := range operations { + t.Run(op.name, func(t *testing.T) { + err := op.fn() + if err == nil { + t.Errorf("%s() with invalid ID = nil, want error", op.name) + } else if !strings.Contains(err.Error(), "invalid task ID") { + t.Errorf("%s() error = %v, want error containing 'invalid task ID'", op.name, err) + } + }) + } +}
\ No newline at end of file diff --git a/internal/task/task.go b/internal/task/task.go index be3b6ce..86c93f4 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -155,12 +155,25 @@ func run(args ...string) error { return nil } -// SetStatus changes the status of the task with the given id. -func SetStatus(id int, status string) error { +// modifyTask runs a modify command with validation +func modifyTask(id int, args ...string) error { if id <= 0 { return fmt.Errorf("invalid task ID: %d", id) } - return run(strconv.Itoa(id), "modify", "status:"+status) + return run(append([]string{strconv.Itoa(id), "modify"}, args...)...) +} + +// simpleTaskCommand runs a simple command on a task with validation +func simpleTaskCommand(id int, command string) error { + if id <= 0 { + return fmt.Errorf("invalid task ID: %d", id) + } + return run(strconv.Itoa(id), command) +} + +// SetStatus changes the status of the task with the given id. +func SetStatus(id int, status string) error { + return modifyTask(id, "status:"+status) } // SetStatusUUID changes the status of the task with the given UUID. @@ -170,42 +183,27 @@ func SetStatusUUID(uuid, status string) error { // Start begins the task with the given id. func Start(id int) error { - if id <= 0 { - return fmt.Errorf("invalid task ID: %d", id) - } - return run(strconv.Itoa(id), "start") + return simpleTaskCommand(id, "start") } // Stop stops the task with the given id. func Stop(id int) error { - if id <= 0 { - return fmt.Errorf("invalid task ID: %d", id) - } - return run(strconv.Itoa(id), "stop") + return simpleTaskCommand(id, "stop") } // Done marks the task with the given id as completed. func Done(id int) error { - if id <= 0 { - return fmt.Errorf("invalid task ID: %d", id) - } - return run(strconv.Itoa(id), "done") + return simpleTaskCommand(id, "done") } // Delete removes the task with the given id. func Delete(id int) error { - if id <= 0 { - return fmt.Errorf("invalid task ID: %d", id) - } - return run(strconv.Itoa(id), "delete") + return simpleTaskCommand(id, "delete") } // SetPriority changes the priority of the task with the given id. func SetPriority(id int, priority string) error { - if id <= 0 { - return fmt.Errorf("invalid task ID: %d", id) - } - return run(strconv.Itoa(id), "modify", "priority:"+priority) + return modifyTask(id, "priority:"+priority) } // AddTags adds tags to the task with the given id. @@ -287,26 +285,17 @@ func SetTags(id int, tags []string) error { // SetRecurrence sets the recurrence for the task with the given id. func SetRecurrence(id int, rec string) error { - if id <= 0 { - return fmt.Errorf("invalid task ID: %d", id) - } - return run(strconv.Itoa(id), "modify", "recur:"+rec) + return modifyTask(id, "recur:"+rec) } // SetDueDate sets the due date for the task with the given id. func SetDueDate(id int, due string) error { - if id <= 0 { - return fmt.Errorf("invalid task ID: %d", id) - } - return run(strconv.Itoa(id), "modify", "due:"+due) + return modifyTask(id, "due:"+due) } // SetDescription changes the description of the task with the given id. func SetDescription(id int, desc string) error { - if id <= 0 { - return fmt.Errorf("invalid task ID: %d", id) - } - return run(strconv.Itoa(id), "modify", "description:"+desc) + return modifyTask(id, "description:"+desc) } // Annotate adds an annotation to the task with the given id. @@ -388,7 +377,16 @@ func Edit(id int) error { // Started tasks are always placed before non-started ones. Tasks without a due // date are placed after tasks with a due date. Overdue tasks are placed at the // very top regardless of other properties. +// +// The sort order is: +// 1. Overdue tasks (oldest due date first) +// 2. Started tasks (not completed) +// 3. High priority tasks +// 4. Tasks with earlier due dates +// 5. Tasks sorted alphabetically by tags +// 6. Tasks sorted by ID (oldest first) func SortTasks(tasks []Task) { + // Helper to join tags in a consistent order for comparison joinTags := func(tags []string) string { if len(tags) == 0 { return "" @@ -398,6 +396,7 @@ func SortTasks(tasks []Task) { return strings.Join(cpy, " ") } + // Convert priority to numeric value for comparison (higher = more important) priVal := func(p string) int { switch p { case "H": @@ -411,6 +410,7 @@ func SortTasks(tasks []Task) { } } + // Parse due date string into time.Time parseDue := func(s string) (time.Time, bool) { if s == "" { return time.Time{}, false @@ -422,6 +422,7 @@ func SortTasks(tasks []Task) { return t, true } + // Check if a task is overdue overdue := func(t Task) bool { du, ok := parseDue(t.Due) return ok && time.Now().After(du) diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go new file mode 100644 index 0000000..7fc734b --- /dev/null +++ b/internal/ui/handlers.go @@ -0,0 +1,437 @@ +package ui + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + + "codeberg.org/snonux/tasksamurai/internal/task" +) + +// handleTextInput provides generic text input handling for all input modes +func (m *Model) handleTextInput(msg tea.KeyMsg, input *textinput.Model, onEnter func(string) error, onExit func()) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + value := input.Value() + if err := onEnter(value); err != nil { + m.statusMsg = fmt.Sprintf("Error: %v", err) + cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg { + return struct{ clearStatus bool }{true} + }) + return m, cmd + } + input.Blur() + onExit() + m.updateTableHeight() + return m, nil + case tea.KeyEsc: + input.Blur() + onExit() + m.updateTableHeight() + return m, nil + } + var cmd tea.Cmd + *input, cmd = input.Update(msg) + return m, cmd +} + +// handleAnnotationMode handles keyboard input when in annotation mode +func (m *Model) handleAnnotationMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + onEnter := func(value string) error { + // Annotation can be empty when replacing (to remove all) + if !m.replaceAnnotations && strings.TrimSpace(value) == "" { + return fmt.Errorf("annotation cannot be empty") + } + + if m.replaceAnnotations { + if err := task.ReplaceAnnotations(m.annotateID, value); err != nil { + return err + } + m.replaceAnnotations = false + } else { + if err := task.Annotate(m.annotateID, value); err != nil { + return err + } + } + m.reload() + return nil + } + + onExit := func() { + m.annotating = false + m.replaceAnnotations = false + } + + model, cmd := m.handleTextInput(msg, &m.annotateInput, onEnter, onExit) + if msg.Type == tea.KeyEnter && m.annotateInput.Value() != "" { + // Start blink after successful annotation + return model, m.startBlink(m.annotateID, false) + } + return model, cmd +} + +// handleDescriptionMode handles keyboard input when editing description +func (m *Model) handleDescriptionMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + onEnter := func(value string) error { + if err := validateDescription(value); err != nil { + return err + } + if err := task.SetDescription(m.descID, value); err != nil { + return err + } + m.reload() + return nil + } + + onExit := func() { + m.descEditing = false + } + + model, cmd := m.handleTextInput(msg, &m.descInput, onEnter, onExit) + if msg.Type == tea.KeyEnter { + return model, m.startBlink(m.descID, false) + } + return model, cmd +} + +// handleTagsMode handles keyboard input when editing tags +func (m *Model) handleTagsMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + onEnter := func(value string) error { + words := strings.Fields(value) + var adds, removes []string + for _, w := range words { + if strings.HasPrefix(w, "-") { + if len(w) > 1 { + tagName := w[1:] + if err := validateTagName(tagName); err != nil { + return fmt.Errorf("remove tag '%s': %w", tagName, err) + } + removes = append(removes, tagName) + } + } else { + if strings.HasPrefix(w, "+") { + w = w[1:] + } + if w != "" { + if err := validateTagName(w); err != nil { + return fmt.Errorf("add tag '%s': %w", w, err) + } + adds = append(adds, w) + } + } + } + if len(adds) > 0 { + if err := task.AddTags(m.tagsID, adds); err != nil { + return err + } + } + if len(removes) > 0 { + if err := task.RemoveTags(m.tagsID, removes); err != nil { + return err + } + } + m.reload() + return nil + } + + onExit := func() { + m.tagsEditing = false + } + + model, cmd := m.handleTextInput(msg, &m.tagsInput, onEnter, onExit) + if msg.Type == tea.KeyEnter { + return model, m.startBlink(m.tagsID, false) + } + return model, cmd +} + +// handleDueEditMode handles due date editing +func (m *Model) handleDueEditMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + if err := task.SetDueDate(m.dueID, m.dueDate.Format("2006-01-02")); err != nil { + m.statusMsg = fmt.Sprintf("Error: %v", err) + cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg { + return struct{ clearStatus bool }{true} + }) + return m, cmd + } + m.dueEditing = false + m.reload() + cmd := m.startBlink(m.dueID, false) + m.updateTableHeight() + return m, cmd + case tea.KeyEsc: + m.dueEditing = false + m.updateTableHeight() + return m, nil + } + + switch msg.String() { + case "h", "left": + m.dueDate = m.dueDate.AddDate(0, 0, -1) + case "l", "right": + m.dueDate = m.dueDate.AddDate(0, 0, 1) + case "k", "up": + m.dueDate = m.dueDate.AddDate(0, 0, -7) + case "j", "down": + m.dueDate = m.dueDate.AddDate(0, 0, 7) + } + return m, nil +} + +// handleRecurrenceMode handles recurrence editing +func (m *Model) handleRecurrenceMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + onEnter := func(value string) error { + if err := validateRecurrence(value); err != nil { + return err + } + if err := task.SetRecurrence(m.recurID, value); err != nil { + return err + } + m.reload() + return nil + } + + onExit := func() { + m.recurEditing = false + } + + model, cmd := m.handleTextInput(msg, &m.recurInput, onEnter, onExit) + if msg.Type == tea.KeyEnter { + return model, m.startBlink(m.recurID, false) + } + return model, cmd +} + +// handlePriorityMode handles priority selection +func (m *Model) handlePriorityMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + priority := priorityOptions[m.priorityIndex] + if err := validatePriority(priority); err != nil { + m.statusMsg = fmt.Sprintf("Error: %v", err) + cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg { + return struct{ clearStatus bool }{true} + }) + return m, cmd + } + if err := task.SetPriority(m.priorityID, priority); err != nil { + m.statusMsg = fmt.Sprintf("Error: %v", err) + cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg { + return struct{ clearStatus bool }{true} + }) + return m, cmd + } + m.prioritySelecting = false + m.reload() + cmd := m.startBlink(m.priorityID, false) + m.updateTableHeight() + return m, cmd + case tea.KeyEsc: + m.prioritySelecting = false + m.updateTableHeight() + return m, nil + } + + switch msg.String() { + case "h", "left": + m.priorityIndex = (m.priorityIndex + len(priorityOptions) - 1) % len(priorityOptions) + case "l", "right": + m.priorityIndex = (m.priorityIndex + 1) % len(priorityOptions) + } + return m, nil +} + +// handleFilterMode handles filter editing +func (m *Model) handleFilterMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + onEnter := func(value string) error { + m.filters = strings.Fields(value) + m.reload() + return nil + } + + onExit := func() { + m.filterEditing = false + } + + return m.handleTextInput(msg, &m.filterInput, onEnter, onExit) +} + +// handleAddTaskMode handles adding a new task +func (m *Model) handleAddTaskMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + oldIDs := make(map[int]struct{}) + for _, tsk := range m.tasks { + oldIDs[tsk.ID] = struct{}{} + } + + if err := task.AddLine(m.addInput.Value()); err != nil { + m.statusMsg = fmt.Sprintf("Error: %v", err) + cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg { + return struct{ clearStatus bool }{true} + }) + return m, cmd + } + + m.addingTask = false + m.addInput.Blur() + m.reload() + + // Find the newly added task + var newID int + row := -1 + for i, tsk := range m.tasks { + if _, ok := oldIDs[tsk.ID]; !ok { + newID = tsk.ID + row = i + break + } + } + + m.updateTableHeight() + if row >= 0 { + prevRow := m.tbl.Cursor() + prevCol := m.tbl.ColumnCursor() + m.tbl.SetCursor(row) + m.tbl.SetColumnCursor(7) // Description column + m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor()) + return m, m.startBlink(newID, false) + } + return m, nil + + case tea.KeyEsc: + m.addingTask = false + m.addInput.Blur() + m.updateTableHeight() + return m, nil + } + + var cmd tea.Cmd + m.addInput, cmd = m.addInput.Update(msg) + return m, cmd +} + +// handleSearchMode handles search input +func (m *Model) handleSearchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + pattern := m.searchInput.Value() + if pattern != "" { + // Check cache first + if cached, ok := searchRegexCache[pattern]; ok { + m.searchRegex = cached + } else { + // Compile and cache if not found + re, err := compileAndCacheRegex(pattern) + if err == nil { + m.searchRegex = re + } else { + m.searchRegex = nil + m.statusMsg = fmt.Sprintf("Invalid regex: %v", err) + } + } + } else { + m.searchRegex = nil + } + m.searching = false + m.searchInput.Blur() + m.reload() + m.updateTableHeight() + + if len(m.searchMatches) > 0 { + match := m.searchMatches[m.searchIndex] + prevRow := m.tbl.Cursor() + prevCol := m.tbl.ColumnCursor() + m.tbl.SetCursor(match.row) + m.tbl.SetColumnCursor(match.col) + m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor()) + } + return m, nil + + case tea.KeyEsc: + m.searching = false + m.searchInput.Blur() + m.updateTableHeight() + return m, nil + } + + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + return m, cmd +} + +// handleBlinkingState handles input when a task is blinking +func (m *Model) handleBlinkingState(msg tea.Msg) (tea.Model, tea.Cmd) { + if _, ok := msg.(tea.KeyMsg); ok { + // Only allow navigation while blinking + prevRow := m.tbl.Cursor() + prevCol := m.tbl.ColumnCursor() + var cmd tea.Cmd + m.tbl, cmd = m.tbl.Update(msg) + if prevRow != m.tbl.Cursor() || prevCol != m.tbl.ColumnCursor() { + m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor()) + } + return m, cmd + } + return m, nil +} + +// handleEditingModes checks if we're in any editing mode and handles it +func (m *Model) handleEditingModes(msg tea.KeyMsg) (handled bool, model tea.Model, cmd tea.Cmd) { + switch { + case m.annotating: + model, cmd = m.handleAnnotationMode(msg) + return true, model, cmd + case m.descEditing: + model, cmd = m.handleDescriptionMode(msg) + return true, model, cmd + case m.tagsEditing: + model, cmd = m.handleTagsMode(msg) + return true, model, cmd + case m.dueEditing: + model, cmd = m.handleDueEditMode(msg) + return true, model, cmd + case m.recurEditing: + model, cmd = m.handleRecurrenceMode(msg) + return true, model, cmd + case m.prioritySelecting: + model, cmd = m.handlePriorityMode(msg) + return true, model, cmd + case m.filterEditing: + model, cmd = m.handleFilterMode(msg) + return true, model, cmd + case m.addingTask: + model, cmd = m.handleAddTaskMode(msg) + return true, model, cmd + case m.searching: + model, cmd = m.handleSearchMode(msg) + return true, model, cmd + } + return false, m, nil +} + +// getSelectedTaskID extracts the task ID from the selected row +func (m *Model) getSelectedTaskID() (int, error) { + row := m.tbl.SelectedRow() + if row == nil { + return 0, fmt.Errorf("no row selected") + } + idStr := ansi.Strip(row[1]) + return strconv.Atoi(idStr) +} + +// getTaskAtCursor returns the task at the current cursor position +func (m *Model) getTaskAtCursor() *task.Task { + cursor := m.tbl.Cursor() + if cursor < 0 || cursor >= len(m.tasks) { + return nil + } + return &m.tasks[cursor] +}
\ No newline at end of file diff --git a/internal/ui/helpers.go b/internal/ui/helpers.go new file mode 100644 index 0000000..71c21db --- /dev/null +++ b/internal/ui/helpers.go @@ -0,0 +1,191 @@ +package ui + +import ( + "fmt" + "regexp" + "strings" + "time" +) + +// Date format used by Taskwarrior +const taskDateFormat = "20060102T150405Z" + +// parseTaskDate parses a date string in Taskwarrior format +func parseTaskDate(dateStr string) (time.Time, error) { + if dateStr == "" { + return time.Time{}, fmt.Errorf("empty date string") + } + return time.Parse(taskDateFormat, dateStr) +} + +// formatTaskDate formats a time as a Taskwarrior date string +func formatTaskDate(t time.Time) string { + return t.UTC().Format(taskDateFormat) +} + +// daysSince returns the number of days since the given time +func daysSince(t time.Time) int { + return int(time.Since(t).Hours() / 24) +} + +// daysUntil returns the number of days until the given time +func daysUntil(t time.Time) int { + now := time.Now() + // Normalize both times to midnight UTC to avoid timezone and fractional day issues + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + target := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + return int(target.Sub(today).Hours() / 24) +} + +// formatDueText returns a human-readable due date string +func formatDueText(dueStr string) string { + if dueStr == "" { + return "" + } + + ts, err := parseTaskDate(dueStr) + if err != nil { + return dueStr + } + + days := daysUntil(ts) + switch days { + case 0: + return "today" + case 1: + return "tomorrow" + case -1: + return "yesterday" + default: + return fmt.Sprintf("%dd", days) + } +} + +// compileAndCacheRegex compiles a regex and adds it to the cache +func compileAndCacheRegex(pattern string) (*regexp.Regexp, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return nil, err + } + + // Limit cache size to prevent memory leak + if len(searchRegexCache) > 100 { + // Clear cache when it gets too large + searchRegexCache = make(map[string]*regexp.Regexp) + } + searchRegexCache[pattern] = re + + return re, nil +} + +// Validation functions + +// validateTagName validates a tag name +func validateTagName(tag string) error { + if tag == "" { + return fmt.Errorf("tag cannot be empty") + } + + // Remove leading + or - for validation + tag = strings.TrimPrefix(strings.TrimPrefix(tag, "+"), "-") + + // Check for invalid characters + if strings.ContainsAny(tag, " \t\n\r") { + return fmt.Errorf("tag cannot contain whitespace") + } + + return nil +} + +// validateTags validates a list of tags +func validateTags(tags []string) error { + for _, tag := range tags { + if err := validateTagName(tag); err != nil { + return fmt.Errorf("invalid tag '%s': %w", tag, err) + } + } + return nil +} + +// validateDueDate validates a due date string +func validateDueDate(due string) error { + if due == "" { + return nil // Empty due date is valid + } + + // Try common formats + formats := []string{ + "2006-01-02", + "2006-01-02T15:04:05", + "2006-01-02T15:04:05Z", + taskDateFormat, + } + + for _, format := range formats { + if _, err := time.Parse(format, due); err == nil { + return nil + } + } + + // Check for relative dates that taskwarrior understands + relatives := []string{"now", "today", "tomorrow", "yesterday", "monday", "tuesday", + "wednesday", "thursday", "friday", "saturday", "sunday", "eod", "eow", "eom", "eoy"} + + due = strings.ToLower(due) + for _, rel := range relatives { + if due == rel || strings.HasPrefix(due, rel+"+") || strings.HasPrefix(due, rel+"-") { + return nil + } + } + + return fmt.Errorf("invalid due date format: %s", due) +} + +// validatePriority validates a priority value +func validatePriority(priority string) error { + switch priority { + case "", "H", "M", "L": + return nil + default: + return fmt.Errorf("invalid priority: %s (must be H, M, L, or empty)", priority) + } +} + +// validateRecurrence validates a recurrence string +func validateRecurrence(recur string) error { + if recur == "" { + return nil // Empty recurrence is valid + } + + // Basic validation - taskwarrior will do the full validation + if len(recur) < 2 { + return fmt.Errorf("recurrence too short") + } + + // Check for common patterns + validPrefixes := []string{"daily", "weekly", "monthly", "yearly", "biweekly", "bimonthly"} + for _, prefix := range validPrefixes { + if strings.HasPrefix(strings.ToLower(recur), prefix) { + return nil + } + } + + // Check for duration format (e.g., "3d", "2w", "1m") + if len(recur) >= 2 { + last := recur[len(recur)-1] + if (last == 'd' || last == 'w' || last == 'm' || last == 'y') && + recur[:len(recur)-1] != "" { + return nil + } + } + + return nil // Let taskwarrior handle complex validation +} + +// validateDescription validates a task description +func validateDescription(desc string) error { + if strings.TrimSpace(desc) == "" { + return fmt.Errorf("description cannot be empty") + } + return nil +}
\ No newline at end of file diff --git a/internal/ui/helpers_test.go b/internal/ui/helpers_test.go new file mode 100644 index 0000000..9ba620c --- /dev/null +++ b/internal/ui/helpers_test.go @@ -0,0 +1,363 @@ +package ui + +import ( + "testing" + "time" +) + +func TestParseTaskDate(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "valid date", + input: "20250627T150405Z", + wantErr: false, + }, + { + name: "empty string", + input: "", + wantErr: true, + }, + { + name: "invalid format", + input: "2025-06-27", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseTaskDate(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseTaskDate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestFormatDueText(t *testing.T) { + now := time.Now() + tests := []struct { + name string + input string + expected string + }{ + { + name: "empty", + input: "", + expected: "", + }, + { + name: "today", + input: now.UTC().Format("20060102T150405Z"), + expected: "today", + }, + { + name: "tomorrow", + input: now.Add(24 * time.Hour).UTC().Format("20060102T150405Z"), + expected: "tomorrow", + }, + { + name: "yesterday", + input: now.Add(-24 * time.Hour).UTC().Format("20060102T150405Z"), + expected: "yesterday", + }, + { + name: "future", + input: now.Add(5 * 24 * time.Hour).UTC().Format("20060102T150405Z"), + expected: "5d", + }, + { + name: "past", + input: now.Add(-3 * 24 * time.Hour).UTC().Format("20060102T150405Z"), + expected: "-3d", + }, + { + name: "invalid", + input: "invalid", + expected: "invalid", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatDueText(tt.input) + if got != tt.expected { + t.Errorf("formatDueText() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestValidateTagName(t *testing.T) { + tests := []struct { + name string + tag string + wantErr bool + }{ + { + name: "valid tag", + tag: "work", + wantErr: false, + }, + { + name: "valid with plus", + tag: "+work", + wantErr: false, + }, + { + name: "valid with minus", + tag: "-work", + wantErr: false, + }, + { + name: "empty tag", + tag: "", + wantErr: true, + }, + { + name: "tag with space", + tag: "my tag", + wantErr: true, + }, + { + name: "tag with tab", + tag: "my\ttag", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateTagName(tt.tag) + if (err != nil) != tt.wantErr { + t.Errorf("validateTagName() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidatePriority(t *testing.T) { + tests := []struct { + name string + priority string + wantErr bool + }{ + { + name: "high", + priority: "H", + wantErr: false, + }, + { + name: "medium", + priority: "M", + wantErr: false, + }, + { + name: "low", + priority: "L", + wantErr: false, + }, + { + name: "empty", + priority: "", + wantErr: false, + }, + { + name: "invalid", + priority: "X", + wantErr: true, + }, + { + name: "lowercase", + priority: "h", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePriority(tt.priority) + if (err != nil) != tt.wantErr { + t.Errorf("validatePriority() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateDescription(t *testing.T) { + tests := []struct { + name string + desc string + wantErr bool + }{ + { + name: "valid description", + desc: "Fix the bug", + wantErr: false, + }, + { + name: "empty description", + desc: "", + wantErr: true, + }, + { + name: "whitespace only", + desc: " ", + wantErr: true, + }, + { + name: "description with whitespace", + desc: " Fix the bug ", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDescription(tt.desc) + if (err != nil) != tt.wantErr { + t.Errorf("validateDescription() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateDueDate(t *testing.T) { |
