diff --git a/internal/timber/todo.go b/internal/timber/todo.go index 04c5cb8..15e6793 100644 --- a/internal/timber/todo.go +++ b/internal/timber/todo.go @@ -27,6 +27,7 @@ type todoCommandOptions struct { pathOnly bool installSkill bool force bool + jsonOutput bool } func NewTodoCommand(runtime Runtime) *cobra.Command { @@ -42,7 +43,8 @@ func NewTodoCommand(runtime Runtime) *cobra.Command { command.Flags().BoolVar(&options.pathOnly, "path", false, "Print the TODO.md path instead of opening it") command.Flags().BoolVar(&options.installSkill, "install-skill", false, "Install the timber-todo skill to ~/.agents/skills") command.Flags().BoolVarP(&options.force, "force", "f", false, "Overwrite an existing installed skill") - command.MarkFlagsMutuallyExclusive("path", "install-skill") + command.Flags().BoolVar(&options.jsonOutput, "json", false, "Print TODO.md items as JSON instead of opening the file") + command.MarkFlagsMutuallyExclusive("path", "install-skill", "json") return command } @@ -73,6 +75,10 @@ func (x *todoCommandOptions) Execute(command *cobra.Command, args []string) erro return err } + if x.jsonOutput { + return x.reportTodoJSON(command, gitDir) + } + editorLine, err := editorCommandLine(x.runtime) if err != nil { return err diff --git a/internal/timber/todo_json.go b/internal/timber/todo_json.go new file mode 100644 index 0000000..1da6d55 --- /dev/null +++ b/internal/timber/todo_json.go @@ -0,0 +1,103 @@ +package timber + +import ( + "errors" + "fmt" + "io/fs" + "os" + "strings" + + json "encoding/json/v2" + + "github.com/spf13/cobra" +) + +// todoJSONSummary counts TODO.md items by status. +type todoJSONSummary struct { + Incomplete int `json:"incomplete"` + Complete int `json:"complete"` +} + +// todoJSONItem is one TODO.md checkbox. ID is the 1-based line number in +// TODO.md, and DependsOn is the ID of the nearest preceding checkbox with +// less indentation, or null for a top-level item. +type todoJSONItem struct { + ID int `json:"id"` + Title string `json:"title"` + DependsOn *int `json:"depends_on"` + Complete bool `json:"complete"` +} + +// todoJSONOutput is the `todo --json` document. +type todoJSONOutput struct { + Summary todoJSONSummary `json:"summary"` + Todos []todoJSONItem `json:"todos"` +} + +// readTodoJSON parses a worktree's TODO.md into its JSON document. A missing +// TODO.md is an empty checklist, not an error, because `timber todo` creates +// the file lazily. +func readTodoJSON(gitDir string) (todoJSONOutput, error) { + contents, err := os.ReadFile(todoFilePath(gitDir)) + if errors.Is(err, fs.ErrNotExist) { + return todoJSONOutput{Todos: []todoJSONItem{}}, nil + } + if err != nil { + return todoJSONOutput{}, fmt.Errorf("open %s: %w", todoFilePath(gitDir), err) + } + return todoJSONFromContents(string(contents)), nil +} + +// todoJSONFromContents converts raw TODO.md text to its JSON document. +func todoJSONFromContents(contents string) todoJSONOutput { + entries, err := parseTodoEntries(strings.NewReader(contents)) + if err != nil { + // parseTodoEntries only fails on scanner I/O errors, which cannot + // happen for an in-memory string; fall through with no items. + return todoJSONOutput{Todos: []todoJSONItem{}} + } + return todoEntriesToJSON(entries) +} + +// todoEntriesToJSON resolves indentation into depends_on links and counts +// the summary. Each item's parent is the nearest preceding item with less +// indentation, tracked with a stack. +func todoEntriesToJSON(entries []todoEntry) todoJSONOutput { + output := todoJSONOutput{Todos: []todoJSONItem{}} + var stack []todoEntry + for _, entry := range entries { + for len(stack) > 0 && stack[len(stack)-1].Indent >= entry.Indent { + stack = stack[:len(stack)-1] + } + item := todoJSONItem{ + ID: entry.ID, + Title: entry.Title, + Complete: entry.Complete, + } + if len(stack) > 0 { + parentID := stack[len(stack)-1].ID + item.DependsOn = &parentID + } + output.Todos = append(output.Todos, item) + stack = append(stack, entry) + if entry.Complete { + output.Summary.Complete++ + } else { + output.Summary.Incomplete++ + } + } + return output +} + +func (x *todoCommandOptions) reportTodoJSON(command *cobra.Command, gitDir string) error { + output, err := readTodoJSON(gitDir) + if err != nil { + return err + } + encoded, err := json.Marshal(output) + if err != nil { + return fmt.Errorf("encode TODO.md as JSON: %w", err) + } + _, err = fmt.Fprintln(command.OutOrStdout(), string(encoded)) + return err +} diff --git a/internal/timber/todo_json_test.go b/internal/timber/todo_json_test.go new file mode 100644 index 0000000..ecb88ee --- /dev/null +++ b/internal/timber/todo_json_test.go @@ -0,0 +1,132 @@ +package timber + +import ( + json "encoding/json/v2" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTodoJSONNestedHierarchy(t *testing.T) { + t.Parallel() + + contents := strings.Join([]string{ + "# TODO", + "- [ ] Top task", + " - [ ] Child task", + " - [x] Grandchild task", + "- [X] Done top", + "- plain bullet", + }, "\n") + "\n" + + output := todoJSONFromContents(contents) + + assert.Equal(t, todoJSONSummary{Incomplete: 2, Complete: 2}, output.Summary) + require.Len(t, output.Todos, 4) + + assert.Equal(t, 2, output.Todos[0].ID) + assert.Equal(t, "Top task", output.Todos[0].Title) + assert.Nil(t, output.Todos[0].DependsOn) + assert.False(t, output.Todos[0].Complete) + + require.NotNil(t, output.Todos[1].DependsOn) + assert.Equal(t, 2, *output.Todos[1].DependsOn) + assert.Equal(t, 3, output.Todos[1].ID) + assert.Equal(t, "Child task", output.Todos[1].Title) + + require.NotNil(t, output.Todos[2].DependsOn) + assert.Equal(t, 3, *output.Todos[2].DependsOn) + assert.Equal(t, 4, output.Todos[2].ID) + assert.True(t, output.Todos[2].Complete) + + assert.Equal(t, 5, output.Todos[3].ID) + assert.Nil(t, output.Todos[3].DependsOn) + assert.True(t, output.Todos[3].Complete) +} + +func TestTodoJSONSiblingReturnsToParent(t *testing.T) { + t.Parallel() + + contents := "- [ ] Parent\n - [ ] First\n - [ ] Second\n- [ ] Other\n" + output := todoJSONFromContents(contents) + + require.Len(t, output.Todos, 4) + assert.Nil(t, output.Todos[0].DependsOn) + require.NotNil(t, output.Todos[1].DependsOn) + assert.Equal(t, 1, *output.Todos[1].DependsOn) + require.NotNil(t, output.Todos[2].DependsOn) + assert.Equal(t, 1, *output.Todos[2].DependsOn) + assert.Nil(t, output.Todos[3].DependsOn) + assert.Equal(t, todoJSONSummary{Incomplete: 4}, output.Summary) +} + +func TestTodoJSONEmptyContents(t *testing.T) { + t.Parallel() + + output := todoJSONFromContents("# TODO\nno items\n") + assert.Equal(t, todoJSONSummary{}, output.Summary) + assert.NotNil(t, output.Todos) + assert.Empty(t, output.Todos) +} + +func TestTodoJSONCommandEmitsFileAsJSON(t *testing.T) { + t.Parallel() + + const branchName = "feature/todo-json" + testRepository := newTestRepository(t) + require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, branchName)).err) + + worktreePath := testRepository.worktreePath(branchName) + writeTodoFile(t, todoPathForWorktree(t, worktreePath), strings.Join([]string{ + "- [ ] Top task", + " - [x] Child task", + "- [ ] Other", + }, "\n")+"\n") + + // EDITOR=false would fail if the editor were launched. + runtime := withTestEnvironment(testRepository.runtime, "EDITOR=false") + result := runTimberFromWithRuntime(t, runtime, worktreePath, "todo", "--json") + require.NoError(t, result.err, result.stderr) + + var output todoJSONOutput + require.NoError(t, json.Unmarshal([]byte(result.stdout), &output)) + assert.Equal(t, todoJSONSummary{Incomplete: 2, Complete: 1}, output.Summary) + require.Len(t, output.Todos, 3) + assert.Equal(t, "Top task", output.Todos[0].Title) + assert.False(t, output.Todos[0].Complete) + assert.Nil(t, output.Todos[0].DependsOn) + require.NotNil(t, output.Todos[1].DependsOn) + assert.Equal(t, 1, *output.Todos[1].DependsOn) + assert.True(t, output.Todos[1].Complete) + assert.Nil(t, output.Todos[2].DependsOn) +} + +func TestTodoJSONCommandTreatsMissingFileAsEmpty(t *testing.T) { + t.Parallel() + + const branchName = "feature/todo-json-missing" + testRepository := newTestRepository(t) + require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, branchName)).err) + + worktreePath := testRepository.worktreePath(branchName) + runtime := withTestEnvironment(testRepository.runtime, "EDITOR=false") + result := runTimberFromWithRuntime(t, runtime, worktreePath, "todo", "--json") + require.NoError(t, result.err, result.stderr) + + var output todoJSONOutput + require.NoError(t, json.Unmarshal([]byte(result.stdout), &output)) + assert.Equal(t, todoJSONSummary{}, output.Summary) + assert.Empty(t, output.Todos) +} + +func TestTodoJSONRejectsPathCombination(t *testing.T) { + t.Parallel() + + testRepository := newTestRepository(t) + runtime := withTestEnvironment(testRepository.runtime, "EDITOR=false") + + result := runTimberFromWithRuntime(t, runtime, testRepository.home, "todo", "--path", "--json") + require.Error(t, result.err) +} diff --git a/internal/timber/todo_progress.go b/internal/timber/todo_progress.go index ae8f6ef..08aefd8 100644 --- a/internal/timber/todo_progress.go +++ b/internal/timber/todo_progress.go @@ -39,40 +39,94 @@ func readTodoProgress(gitDir string) (todoProgress, error) { return progress, parseErr } +// todoEntry is one Markdown checkbox in TODO.md. ID is the 1-based line +// number in the file, and Indent is the leading-whitespace width used to +// resolve parent-child relationships. +type todoEntry struct { + ID int + Indent int + Title string + Complete bool +} + // parseTodoProgress counts checklist items line by line. Items may be nested, // so leading whitespace is ignored, and only lines whose first non-space // characters form a checkbox count toward the totals. func parseTodoProgress(reader io.Reader) (todoProgress, error) { + entries, err := parseTodoEntries(reader) + if err != nil { + return todoProgress{}, err + } var progress todoProgress + progress.Total = len(entries) + for _, entry := range entries { + if entry.Complete { + progress.Done++ + } + } + return progress, nil +} + +// parseTodoEntries returns one entry per checkbox line in file order. +// Non-checkbox lines are skipped but still advance the 1-based line +// numbers used as IDs. +func parseTodoEntries(reader io.Reader) ([]todoEntry, error) { + var entries []todoEntry scanner := bufio.NewScanner(reader) + lineNumber := 0 for scanner.Scan() { - checked, ok := todoItemChecked(strings.TrimSpace(scanner.Text())) + lineNumber++ + line := scanner.Text() + checked, title, ok := todoItemChecked(strings.TrimSpace(line)) if !ok { continue } - progress.Total++ - if checked { - progress.Done++ - } + entries = append(entries, todoEntry{ + ID: lineNumber, + Indent: todoIndentWidth(line), + Title: title, + Complete: checked, + }) } if err := scanner.Err(); err != nil { - return todoProgress{}, fmt.Errorf("read TODO.md: %w", err) + return nil, fmt.Errorf("read TODO.md: %w", err) } - return progress, nil + return entries, nil +} + +// todoIndentWidth measures leading whitespace with tabs expanded so +// mixed indentation still nests deterministically. +func todoIndentWidth(line string) int { + width := 0 + for _, char := range line { + switch char { + case ' ': + width++ + case '\t': + width += 8 - (width % 8) + default: + return width + } + } + return width } -// todoItemChecked reports whether a trimmed line is a Markdown checkbox and -// whether that checkbox is checked. -func todoItemChecked(line string) (checked bool, ok bool) { +// todoItemChecked reports whether a trimmed line is a Markdown checkbox, +// whether that checkbox is checked, and the checkbox title. +func todoItemChecked(line string) (checked bool, title string, ok bool) { item, found := strings.CutPrefix(line, "- ") if !found { - return false, false + return false, "", false } + var marker string switch { case strings.HasPrefix(item, "[ ]"): - return false, true + marker = "[ ]" case strings.HasPrefix(item, "[x]"), strings.HasPrefix(item, "[X]"): - return true, true + checked = true + marker = item[:3] + default: + return false, "", false } - return false, false + return checked, strings.TrimSpace(strings.TrimPrefix(item[len(marker):], " ")), true } diff --git a/mise.lock b/mise.lock index 43a4dea..1fd7b65 100644 --- a/mise.lock +++ b/mise.lock @@ -40,36 +40,36 @@ url = "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8 url_api = "https://api.github.com/repos/gitleaks/gitleaks/releases/assets/378333178" [[tools.go]] -version = "1.27.1" +version = "1.27.2" backend = "core:go" [tools.go."platforms.linux-arm64"] -checksum = "sha256:3450b45a3f9ee8568792736a5c5e70a1f2e9b36c35a8f74958c03e51d7d92bec" -url = "https://dl.google.com/go/go1.27.1.linux-arm64.tar.gz" +checksum = "sha256:94f3e30b8e374bc285e7dadc11e0865726b9bc6e85b841ccceaabc0214c6b7c8" +url = "https://dl.google.com/go/go1.27.2.linux-arm64.tar.gz" [tools.go."platforms.linux-arm64-musl"] -checksum = "sha256:3450b45a3f9ee8568792736a5c5e70a1f2e9b36c35a8f74958c03e51d7d92bec" -url = "https://dl.google.com/go/go1.27.1.linux-arm64.tar.gz" +checksum = "sha256:94f3e30b8e374bc285e7dadc11e0865726b9bc6e85b841ccceaabc0214c6b7c8" +url = "https://dl.google.com/go/go1.27.2.linux-arm64.tar.gz" [tools.go."platforms.linux-x64"] -checksum = "sha256:63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445" -url = "https://dl.google.com/go/go1.27.1.linux-amd64.tar.gz" +checksum = "sha256:ecbadb99091a3f46e31f5f934b068b1864eafa7995211b39eaddf76996045fe5" +url = "https://dl.google.com/go/go1.27.2.linux-amd64.tar.gz" [tools.go."platforms.linux-x64-musl"] -checksum = "sha256:63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445" -url = "https://dl.google.com/go/go1.27.1.linux-amd64.tar.gz" +checksum = "sha256:ecbadb99091a3f46e31f5f934b068b1864eafa7995211b39eaddf76996045fe5" +url = "https://dl.google.com/go/go1.27.2.linux-amd64.tar.gz" [tools.go."platforms.macos-arm64"] -checksum = "sha256:ee215d57e0ec269c60cc9ceca68e6bda321ba9ee5afe24f4b0988703c2d87d12" -url = "https://dl.google.com/go/go1.27.1.darwin-arm64.tar.gz" +checksum = "sha256:76812b213b1b2302c978d28fa52fa92d541704b9e7d9d5db8002c50e4018c4c5" +url = "https://dl.google.com/go/go1.27.2.darwin-arm64.tar.gz" [tools.go."platforms.macos-x64"] -checksum = "sha256:8f8f52c6649542cf027bbc9b9c68d1ec042f9f34808a40413f0b8b3f66f3caa4" -url = "https://dl.google.com/go/go1.27.1.darwin-amd64.tar.gz" +checksum = "sha256:587b59182488b23aa6e5fc25110405a3e0e5b38ed2f5b2f46ed13c32aee356fe" +url = "https://dl.google.com/go/go1.27.2.darwin-amd64.tar.gz" [tools.go."platforms.windows-x64"] -checksum = "sha256:a3911b5e0e1b1053f25ed0675f4c1c6aad1e2bfcf253df2b9be4caabd2edd95d" -url = "https://dl.google.com/go/go1.27.1.windows-amd64.zip" +checksum = "sha256:1314008898bd40df77af4b014f777f08873dbdfbcd3d92308728ee03304fe04f" +url = "https://dl.google.com/go/go1.27.2.windows-amd64.zip" [[tools."go:github.com/Antonboom/testifylint"]] version = "1.6.4" diff --git a/mise.toml b/mise.toml index eee7fbb..24f261c 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,5 @@ [tools] -go = "1.27.1" +go = "1.27.2" ##################### # Development Tasks #