diff --git a/cmd/super-ollama/main.go b/cmd/super-ollama/main.go index 3dc36df4b7b..348726c5a54 100644 --- a/cmd/super-ollama/main.go +++ b/cmd/super-ollama/main.go @@ -49,7 +49,7 @@ func main() { newAskCmd(), newChatCmd(), newStubCmd("email", "Email assistant (coming in a later phase)"), - newStubCmd("todo", "TODO manager (coming in a later phase)"), + newTodoCmd(), newStubCmd("snap", "Screenshot capture (coming in a later phase)"), newStubCmd("learn", "Learning-loop re-index (coming in a later phase)"), newConfigShowCmd(), diff --git a/cmd/super-ollama/todo.go b/cmd/super-ollama/todo.go new file mode 100644 index 00000000000..8b2805264a8 --- /dev/null +++ b/cmd/super-ollama/todo.go @@ -0,0 +1,267 @@ +package main + +import ( + "context" + "fmt" + "os/signal" + "strings" + "syscall" + + "github.com/spf13/cobra" + + "github.com/ollama/ollama/internal/config" + "github.com/ollama/ollama/internal/engine" + "github.com/ollama/ollama/internal/todo" + "github.com/ollama/ollama/internal/ui" +) + +// newTodoCmd builds the `todo` command tree. +func newTodoCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "todo", + Short: "Manage your ~/TODO.md from the terminal", + } + cmd.AddCommand( + todoAddCmd(), + todoDoneCmd(), + todoListCmd(), + todoSearchCmd(), + todoSuggestCmd(), + todoTriageCmd(), + ) + return cmd +} + +// loadTodo reads the configured TODO file and returns both the list and the path. +func loadTodo() (*todo.List, string, string, error) { + cfg, err := config.Load() + if err != nil { + return nil, "", "", err + } + list, err := todo.LoadFile(cfg.TodoPath) + return list, cfg.TodoPath, cfg.DBPath, err +} + +// saveTodo writes the list and rebuilds the SQLite index. +func saveTodo(path, dbPath string, list *todo.List) error { + if err := todo.SaveFile(path, list); err != nil { + return err + } + db, err := todo.OpenStore(dbPath) + if err != nil { + // Non-fatal: the Markdown file is the source of truth. + ui.Eprintf("warning: could not open index DB: %v\n", err) + return nil + } + defer db.Close() + if err := todo.RebuildIndex(db, list); err != nil { + ui.Eprintf("warning: could not rebuild index: %v\n", err) + } + return nil +} + +// printItem formats a single item for console output. +func printItem(item *todo.Item) { + check := "[ ]" + if item.Done { + check = "[x]" + } + ui.Printf("%s [%d] %s\n", check, item.ID, item.Text) +} + +// ── add ─────────────────────────────────────────────────────────────────────── + +func todoAddCmd() *cobra.Command { + return &cobra.Command{ + Use: "add ", + Short: "Add a new task to the active list", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + text := strings.Join(args, " ") + list, path, dbPath, err := loadTodo() + if err != nil { + return err + } + item := todo.AddItem(list, text) + if err := saveTodo(path, dbPath, list); err != nil { + return err + } + ui.Printf("Added [%d] %s\n", item.ID, item.Text) + return nil + }, + } +} + +// ── done ────────────────────────────────────────────────────────────────────── + +func todoDoneCmd() *cobra.Command { + return &cobra.Command{ + Use: "done ", + Short: "Mark a task as done (by ID or partial text match)", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + query := strings.Join(args, " ") + list, path, dbPath, err := loadTodo() + if err != nil { + return err + } + item := todo.MarkDone(list, query) + if item == nil { + return fmt.Errorf("no active task matched %q", query) + } + if err := saveTodo(path, dbPath, list); err != nil { + return err + } + ui.Printf("Done [%d] %s\n", item.ID, item.Text) + return nil + }, + } +} + +// ── list ────────────────────────────────────────────────────────────────────── + +func todoListCmd() *cobra.Command { + var ( + all bool + tag string + ) + cmd := &cobra.Command{ + Use: "list", + Short: "List tasks", + RunE: func(cmd *cobra.Command, args []string) error { + list, _, _, err := loadTodo() + if err != nil { + return err + } + items := todo.FilterByTag(list, tag, all) + if len(items) == 0 { + ui.Println("No tasks found.") + return nil + } + for _, item := range items { + printItem(item) + } + return nil + }, + } + cmd.Flags().BoolVar(&all, "all", false, "include done tasks") + cmd.Flags().StringVar(&tag, "tag", "", "filter by tag (e.g. work)") + return cmd +} + +// ── search ──────────────────────────────────────────────────────────────────── + +func todoSearchCmd() *cobra.Command { + return &cobra.Command{ + Use: "search ", + Short: "Search tasks by text", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + query := strings.Join(args, " ") + list, _, _, err := loadTodo() + if err != nil { + return err + } + results := todo.SearchItems(list, query) + if len(results) == 0 { + ui.Printf("No tasks matched %q.\n", query) + return nil + } + for _, item := range results { + printItem(item) + } + return nil + }, + } +} + +// ── suggest ─────────────────────────────────────────────────────────────────── + +const suggestPromptFmt = `Given this list of tasks, rank them by urgency and importance. +Return a numbered list with a one-sentence reason per item. + +Tasks: +%s` + +func todoSuggestCmd() *cobra.Command { + return &cobra.Command{ + Use: "suggest", + Short: "AI ranks active tasks by inferred urgency", + RunE: func(cmd *cobra.Command, args []string) error { + list, _, _, err := loadTodo() + if err != nil { + return err + } + if len(list.Active) == 0 { + ui.Println("No active tasks to suggest.") + return nil + } + + model, err := resolveModel() + if err != nil { + return err + } + prompt := fmt.Sprintf(suggestPromptFmt, todo.ActiveSummary(list)) + + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + eng, err := engine.New(context.Background()) + if err != nil { + return err + } + defer eng.Close() + + out, err := eng.Generate(ctx, model, prompt, nil) + if err != nil { + return err + } + ui.Println(out) + return nil + }, + } +} + +// ── triage ──────────────────────────────────────────────────────────────────── + +const triagePromptFmt = `You are a personal productivity assistant. +Read the following TODO list and propose a reordered version of the active tasks, +placing the most important and urgent ones first. +Provide a brief explanation for your ordering. + +%s` + +func todoTriageCmd() *cobra.Command { + return &cobra.Command{ + Use: "triage", + Short: "AI reads the full list and proposes a reordered version", + RunE: func(cmd *cobra.Command, args []string) error { + list, _, _, err := loadTodo() + if err != nil { + return err + } + + model, err := resolveModel() + if err != nil { + return err + } + prompt := fmt.Sprintf(triagePromptFmt, todo.FullSummary(list)) + + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + eng, err := engine.New(context.Background()) + if err != nil { + return err + } + defer eng.Close() + + out, err := eng.Generate(ctx, model, prompt, nil) + if err != nil { + return err + } + ui.Println(out) + return nil + }, + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 1e6dbe87c30..41405b7a294 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,12 @@ const defaultModel = "gemma3:1b" // Config holds super-ollama runtime settings from config.toml. type Config struct { DefaultModel string `toml:"default_model"` + // TodoPath is the path to the Markdown TODO file. + // Defaults to ~/TODO.md. Supports ~ expansion. + TodoPath string `toml:"todo_path"` + // DBPath is the path to the SQLite index database. + // Defaults to ~/.super-ollama/data.db. Supports ~ expansion. + DBPath string `toml:"db_path"` } // ResolvedPath returns the path to the active config file. @@ -36,7 +42,7 @@ func Load() (Config, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - return Config{DefaultModel: defaultModel}, nil + return applyDefaults(Config{}) } return Config{}, err } @@ -44,8 +50,39 @@ func Load() (Config, error) { if err := toml.Unmarshal(data, &c); err != nil { return Config{}, err } + return applyDefaults(c) +} + +// applyDefaults fills in any empty fields with their default values, expanding +// leading ~ in path fields to the user home directory. +func applyDefaults(c Config) (Config, error) { + home, err := os.UserHomeDir() + if err != nil { + return c, err + } if strings.TrimSpace(c.DefaultModel) == "" { c.DefaultModel = defaultModel } + if strings.TrimSpace(c.TodoPath) == "" { + c.TodoPath = filepath.Join(home, "TODO.md") + } else { + c.TodoPath = expandHome(c.TodoPath, home) + } + if strings.TrimSpace(c.DBPath) == "" { + c.DBPath = filepath.Join(home, ".super-ollama", "data.db") + } else { + c.DBPath = expandHome(c.DBPath, home) + } return c, nil } + +// expandHome replaces a leading ~ with the provided home directory. +func expandHome(path, home string) string { + if path == "~" { + return home + } + if strings.HasPrefix(path, "~/") { + return filepath.Join(home, path[2:]) + } + return path +} diff --git a/internal/todo/store.go b/internal/todo/store.go new file mode 100644 index 00000000000..2ca16b998a3 --- /dev/null +++ b/internal/todo/store.go @@ -0,0 +1,114 @@ +package todo + +import ( + "database/sql" + "os" + "path/filepath" + "strings" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// OpenStore opens (or creates) the SQLite database at dbPath, initialising +// the schema when needed. +func OpenStore(dbPath string) (*sql.DB, error) { + if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { + return nil, err + } + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, err + } + if err := initSchema(db); err != nil { + db.Close() + return nil, err + } + return db, nil +} + +func initSchema(db *sql.DB) error { + _, err := db.Exec(`CREATE TABLE IF NOT EXISTS todos ( + id INTEGER PRIMARY KEY, + created_at INTEGER, + done_at INTEGER, + done INTEGER NOT NULL DEFAULT 0, + text TEXT, + tags TEXT + )`) + return err +} + +// RebuildIndex deletes all rows and re-inserts the full contents of list in a +// single transaction. +func RebuildIndex(db *sql.DB, list *List) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() //nolint:errcheck + + if _, err := tx.Exec("DELETE FROM todos"); err != nil { + return err + } + + all := append(append([]*Item{}, list.Active...), list.Done...) + for _, item := range all { + var createdAt, doneAt int64 + if !item.CreatedAt.IsZero() { + createdAt = item.CreatedAt.Unix() + } + if !item.DoneAt.IsZero() { + doneAt = item.DoneAt.Unix() + } + done := 0 + if item.Done { + done = 1 + } + if _, err := tx.Exec( + "INSERT INTO todos (id, created_at, done_at, done, text, tags) VALUES (?,?,?,?,?,?)", + item.ID, createdAt, doneAt, done, item.Text, strings.Join(item.Tags, ","), + ); err != nil { + return err + } + } + return tx.Commit() +} + +// QueryStore returns items matching query (case-insensitive LIKE search on text). +func QueryStore(db *sql.DB, query string) ([]*Item, error) { + rows, err := db.Query( + "SELECT id, created_at, done_at, done, text, tags FROM todos WHERE text LIKE ? COLLATE NOCASE", + "%"+query+"%", + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []*Item + for rows.Next() { + var ( + item Item + createdAt int64 + doneAt int64 + done int + tagsStr string + ) + if err := rows.Scan(&item.ID, &createdAt, &doneAt, &done, &item.Text, &tagsStr); err != nil { + return nil, err + } + if createdAt != 0 { + item.CreatedAt = time.Unix(createdAt, 0) + } + if doneAt != 0 { + item.DoneAt = time.Unix(doneAt, 0) + } + item.Done = done != 0 + if tagsStr != "" { + item.Tags = strings.Split(tagsStr, ",") + } + items = append(items, &item) + } + return items, rows.Err() +} diff --git a/internal/todo/todo.go b/internal/todo/todo.go new file mode 100644 index 00000000000..a0091a92d55 --- /dev/null +++ b/internal/todo/todo.go @@ -0,0 +1,266 @@ +// Package todo manages a single Markdown file as a TODO list with a SQLite +// derived index. The Markdown file is the source of truth; SQLite is rebuilt +// on every write. +package todo + +import ( + "bufio" + "bytes" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +const dateLayout = "2006-01-02" + +// Item represents a single TODO entry. +type Item struct { + ID int + Text string // raw text including #tag tokens + Tags []string + Done bool + CreatedAt time.Time + DoneAt time.Time // zero value when not done +} + +// List is the in-memory representation of the TODO.md file. +type List struct { + Active []*Item + Done []*Item +} + +var ( + activeLineRe = regexp.MustCompile(`^- \[ \] (.+?)(?:\s+)?$`) + doneLineRe = regexp.MustCompile(`^- \[x\] (.+?)(?:\s+)?$`) + tagRe = regexp.MustCompile(`#(\w+)`) +) + +// Parse parses the bytes of a TODO.md file into a List. +func Parse(data []byte) (*List, error) { + list := &List{} + scanner := bufio.NewScanner(bytes.NewReader(data)) + var section string // "active" or "done" + + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + + switch trimmed { + case "## Active": + section = "active" + continue + case "## Done": + section = "done" + continue + } + + switch section { + case "active": + if m := activeLineRe.FindStringSubmatch(line); m != nil { + item := &Item{Text: m[1], Done: false} + item.Tags = extractTags(item.Text) + if m[2] != "" { + item.ID, _ = strconv.Atoi(m[2]) + } + if m[3] != "" { + item.CreatedAt, _ = time.Parse(dateLayout, m[3]) + } + list.Active = append(list.Active, item) + } + case "done": + if m := doneLineRe.FindStringSubmatch(line); m != nil { + item := &Item{Text: m[1], Done: true} + item.Tags = extractTags(item.Text) + if m[2] != "" { + item.ID, _ = strconv.Atoi(m[2]) + } + if m[3] != "" { + item.CreatedAt, _ = time.Parse(dateLayout, m[3]) + } + if m[4] != "" { + item.DoneAt, _ = time.Parse(dateLayout, m[4]) + } + list.Done = append(list.Done, item) + } + } + } + return list, scanner.Err() +} + +// Marshal encodes a List to the canonical Markdown format. +func Marshal(list *List) []byte { + var b bytes.Buffer + b.WriteString("# TODO\n\n") + b.WriteString("## Active\n\n") + for _, item := range list.Active { + fmt.Fprintf(&b, "- [ ] %s \n", + item.Text, item.ID, item.CreatedAt.Format(dateLayout)) + } + b.WriteString("\n## Done\n\n") + for _, item := range list.Done { + doneStr := "" + if !item.DoneAt.IsZero() { + doneStr = " done:" + item.DoneAt.Format(dateLayout) + } + fmt.Fprintf(&b, "- [x] %s \n", + item.Text, item.ID, item.CreatedAt.Format(dateLayout), doneStr) + } + return b.Bytes() +} + +// LoadFile reads and parses the TODO file. A missing file returns an empty List. +func LoadFile(path string) (*List, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &List{}, nil + } + return nil, err + } + return Parse(data) +} + +// SaveFile writes the List to disk, creating parent directories as needed. +func SaveFile(path string, list *List) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, Marshal(list), 0o644) +} + +// AddItem appends a new active item and returns it. +func AddItem(list *List, text string) *Item { + item := &Item{ + ID: nextID(list), + Text: text, + Tags: extractTags(text), + Done: false, + CreatedAt: time.Now(), + } + list.Active = append(list.Active, item) + return item +} + +// MarkDone marks the first active item matching query as done. +// query is matched as an integer ID first, then as a case-insensitive substring +// of the item text. Returns the moved item, or nil when nothing matches. +func MarkDone(list *List, query string) *Item { + if id, err := strconv.Atoi(strings.TrimSpace(query)); err == nil { + for i, item := range list.Active { + if item.ID == id { + return moveToDone(list, i) + } + } + } + q := strings.ToLower(strings.TrimSpace(query)) + for i, item := range list.Active { + if strings.Contains(strings.ToLower(item.Text), q) { + return moveToDone(list, i) + } + } + return nil +} + +// FilterByTag returns active items (or all items when all=true) that carry tag. +// An empty tag returns all matching items. +func FilterByTag(list *List, tag string, includeAll bool) []*Item { + var pool []*Item + pool = append(pool, list.Active...) + if includeAll { + pool = append(pool, list.Done...) + } + if tag == "" { + return pool + } + tag = strings.ToLower(strings.TrimPrefix(tag, "#")) + var out []*Item + for _, item := range pool { + for _, t := range item.Tags { + if strings.ToLower(t) == tag { + out = append(out, item) + break + } + } + } + return out +} + +// SearchItems returns all items (active and done) whose text contains query +// (case-insensitive). +func SearchItems(list *List, query string) []*Item { + q := strings.ToLower(query) + var out []*Item + for _, item := range append(list.Active, list.Done...) { + if strings.Contains(strings.ToLower(item.Text), q) { + out = append(out, item) + } + } + return out +} + +// ActiveSummary returns a newline-separated list of active item texts suitable +// for injecting into an LLM prompt. +func ActiveSummary(list *List) string { + if len(list.Active) == 0 { + return "(no active tasks)" + } + var lines []string + for _, item := range list.Active { + lines = append(lines, fmt.Sprintf("[%d] %s", item.ID, item.Text)) + } + return strings.Join(lines, "\n") +} + +// FullSummary returns a summary of active and done items for triage prompts. +func FullSummary(list *List) string { + var b strings.Builder + b.WriteString("## Active\n") + for _, item := range list.Active { + fmt.Fprintf(&b, "- [%d] %s\n", item.ID, item.Text) + } + b.WriteString("\n## Done\n") + for _, item := range list.Done { + fmt.Fprintf(&b, "- [%d] %s\n", item.ID, item.Text) + } + return b.String() +} + +// moveToDone removes index idx from Active, stamps DoneAt, appends to Done. +func moveToDone(list *List, idx int) *Item { + item := list.Active[idx] + item.Done = true + item.DoneAt = time.Now() + list.Active = append(list.Active[:idx], list.Active[idx+1:]...) + list.Done = append(list.Done, item) + return item +} + +// nextID returns the next monotonically incrementing ID. +func nextID(list *List) int { + max := 0 + for _, item := range list.Active { + if item.ID > max { + max = item.ID + } + } + for _, item := range list.Done { + if item.ID > max { + max = item.ID + } + } + return max + 1 +} + +// extractTags collects all #word tokens from text. +func extractTags(text string) []string { + matches := tagRe.FindAllStringSubmatch(text, -1) + tags := make([]string, 0, len(matches)) + for _, m := range matches { + tags = append(tags, m[1]) + } + return tags +} diff --git a/internal/todo/todo_test.go b/internal/todo/todo_test.go new file mode 100644 index 00000000000..60fa0e7b14a --- /dev/null +++ b/internal/todo/todo_test.go @@ -0,0 +1,311 @@ +package todo + +import ( + "strings" + "testing" + "time" +) + +// ── Parse ──────────────────────────────────────────────────────────────────── + +func TestParseEmpty(t *testing.T) { + list, err := Parse([]byte("# TODO\n\n## Active\n\n## Done\n\n")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(list.Active) != 0 || len(list.Done) != 0 { + t.Errorf("expected empty lists, got active=%d done=%d", len(list.Active), len(list.Done)) + } +} + +func TestParseActiveItem(t *testing.T) { + md := "# TODO\n\n## Active\n\n- [ ] #work finish slide deck \n" + list, err := Parse([]byte(md)) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if len(list.Active) != 1 { + t.Fatalf("expected 1 active item, got %d", len(list.Active)) + } + item := list.Active[0] + if item.ID != 1 { + t.Errorf("id: want 1, got %d", item.ID) + } + if item.Text != "#work finish slide deck" { + t.Errorf("text: want %q, got %q", "#work finish slide deck", item.Text) + } + if len(item.Tags) != 1 || item.Tags[0] != "work" { + t.Errorf("tags: want [work], got %v", item.Tags) + } + wantDate := "2025-11-01" + if got := item.CreatedAt.Format(dateLayout); got != wantDate { + t.Errorf("created_at: want %s, got %s", wantDate, got) + } + if item.Done { + t.Error("active item should not be Done") + } +} + +func TestParseDoneItem(t *testing.T) { + md := "# TODO\n\n## Active\n\n## Done\n\n- [x] #work send Q3 report \n" + list, err := Parse([]byte(md)) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if len(list.Done) != 1 { + t.Fatalf("expected 1 done item, got %d", len(list.Done)) + } + item := list.Done[0] + if item.ID != 0 { + t.Errorf("id: want 0, got %d", item.ID) + } + if !item.Done { + t.Error("done item should have Done=true") + } + if item.DoneAt.Format(dateLayout) != "2025-10-30" { + t.Errorf("done_at: want 2025-10-30, got %s", item.DoneAt.Format(dateLayout)) + } +} + +func TestParseMultiple(t *testing.T) { + md := `# TODO + +## Active + +- [ ] #work finish slide deck for Monday +- [ ] #personal call dentist + +## Done + +- [x] #work send Q3 report +` + list, err := Parse([]byte(md)) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if len(list.Active) != 2 { + t.Errorf("expected 2 active, got %d", len(list.Active)) + } + if len(list.Done) != 1 { + t.Errorf("expected 1 done, got %d", len(list.Done)) + } +} + +// ── Marshal ────────────────────────────────────────────────────────────────── + +func TestMarshalRoundTrip(t *testing.T) { + list := &List{ + Active: []*Item{ + {ID: 1, Text: "#work finish slide deck", Tags: []string{"work"}, CreatedAt: mustDate("2025-11-01")}, + }, + Done: []*Item{ + {ID: 0, Text: "#work send Q3 report", Tags: []string{"work"}, Done: true, + CreatedAt: mustDate("2025-10-28"), DoneAt: mustDate("2025-10-30")}, + }, + } + data := Marshal(list) + + list2, err := Parse(data) + if err != nil { + t.Fatalf("parse after marshal: %v", err) + } + if len(list2.Active) != 1 || list2.Active[0].ID != 1 { + t.Errorf("active item not preserved: %+v", list2.Active) + } + if len(list2.Done) != 1 || list2.Done[0].ID != 0 { + t.Errorf("done item not preserved: %+v", list2.Done) + } +} + +// ── AddItem ────────────────────────────────────────────────────────────────── + +func TestAddItem(t *testing.T) { + list := &List{} + item := AddItem(list, "#work write tests") + if item.ID != 1 { + t.Errorf("first id: want 1, got %d", item.ID) + } + if item.Text != "#work write tests" { + t.Errorf("text mismatch: %q", item.Text) + } + if len(item.Tags) != 1 || item.Tags[0] != "work" { + t.Errorf("tags: want [work], got %v", item.Tags) + } + if item.Done { + t.Error("new item should not be Done") + } + if item.CreatedAt.IsZero() { + t.Error("CreatedAt should be set") + } + if len(list.Active) != 1 { + t.Errorf("list should have 1 active item") + } +} + +func TestAddItemIDIncrement(t *testing.T) { + list := &List{ + Active: []*Item{{ID: 5}}, + Done: []*Item{{ID: 3}}, + } + item := AddItem(list, "new task") + if item.ID != 6 { + t.Errorf("expected ID 6, got %d", item.ID) + } +} + +// ── MarkDone ───────────────────────────────────────────────────────────────── + +func TestMarkDoneByID(t *testing.T) { + list := &List{ + Active: []*Item{ + {ID: 1, Text: "task one"}, + {ID: 2, Text: "task two"}, + }, + } + item := MarkDone(list, "1") + if item == nil { + t.Fatal("expected item, got nil") + } + if item.ID != 1 { + t.Errorf("wrong item marked done: id=%d", item.ID) + } + if len(list.Active) != 1 { + t.Errorf("should have 1 active item left, got %d", len(list.Active)) + } + if len(list.Done) != 1 { + t.Errorf("should have 1 done item, got %d", len(list.Done)) + } + if !item.Done { + t.Error("item.Done should be true") + } + if item.DoneAt.IsZero() { + t.Error("DoneAt should be set") + } +} + +func TestMarkDoneByText(t *testing.T) { + list := &List{ + Active: []*Item{ + {ID: 1, Text: "finish the slide deck"}, + }, + } + item := MarkDone(list, "slide deck") + if item == nil { + t.Fatal("expected match, got nil") + } + if item.ID != 1 { + t.Errorf("wrong item: id=%d", item.ID) + } +} + +func TestMarkDoneNotFound(t *testing.T) { + list := &List{ + Active: []*Item{{ID: 1, Text: "something"}}, + } + if got := MarkDone(list, "nothing"); got != nil { + t.Errorf("expected nil, got %+v", got) + } +} + +// ── FilterByTag ─────────────────────────────────────────────────────────────── + +func TestFilterByTag(t *testing.T) { + list := &List{ + Active: []*Item{ + {ID: 1, Text: "#work task", Tags: []string{"work"}}, + {ID: 2, Text: "#personal task", Tags: []string{"personal"}}, + }, + Done: []*Item{ + {ID: 0, Text: "#work done", Tags: []string{"work"}, Done: true}, + }, + } + + workOnly := FilterByTag(list, "work", false) + if len(workOnly) != 1 { + t.Errorf("expected 1 work item (active only), got %d", len(workOnly)) + } + + workAll := FilterByTag(list, "work", true) + if len(workAll) != 2 { + t.Errorf("expected 2 work items (all), got %d", len(workAll)) + } + + all := FilterByTag(list, "", false) + if len(all) != 2 { + t.Errorf("expected 2 active items with empty tag, got %d", len(all)) + } +} + +// ── SearchItems ─────────────────────────────────────────────────────────────── + +func TestSearchItems(t *testing.T) { + list := &List{ + Active: []*Item{ + {ID: 1, Text: "finish slide deck"}, + {ID: 2, Text: "call dentist"}, + }, + Done: []*Item{ + {ID: 0, Text: "send Q3 report", Done: true}, + }, + } + results := SearchItems(list, "slide") + if len(results) != 1 || results[0].ID != 1 { + t.Errorf("unexpected search results: %v", results) + } + allResults := SearchItems(list, "") + if len(allResults) != 3 { + t.Errorf("empty query should return all 3 items, got %d", len(allResults)) + } +} + +// ── extractTags ─────────────────────────────────────────────────────────────── + +func TestExtractTags(t *testing.T) { + tags := extractTags("#work finish #important task") + if len(tags) != 2 { + t.Fatalf("expected 2 tags, got %v", tags) + } + if tags[0] != "work" || tags[1] != "important" { + t.Errorf("unexpected tags: %v", tags) + } +} + +func TestExtractTagsNone(t *testing.T) { + tags := extractTags("plain text with no tags") + if len(tags) != 0 { + t.Errorf("expected 0 tags, got %v", tags) + } +} + +// ── ActiveSummary / FullSummary ─────────────────────────────────────────────── + +func TestActiveSummaryEmpty(t *testing.T) { + list := &List{} + s := ActiveSummary(list) + if !strings.Contains(s, "no active tasks") { + t.Errorf("unexpected empty summary: %q", s) + } +} + +func TestActiveSummaryContent(t *testing.T) { + list := &List{ + Active: []*Item{ + {ID: 1, Text: "task one"}, + {ID: 2, Text: "task two"}, + }, + } + s := ActiveSummary(list) + if !strings.Contains(s, "[1] task one") || !strings.Contains(s, "[2] task two") { + t.Errorf("summary missing expected content: %q", s) + } +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func mustDate(s string) time.Time { + t, err := time.Parse(dateLayout, s) + if err != nil { + panic(err) + } + return t +}