Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion internal/timber/todo.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type todoCommandOptions struct {
pathOnly bool
installSkill bool
force bool
jsonOutput bool
}

func NewTodoCommand(runtime Runtime) *cobra.Command {
Expand All @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions internal/timber/todo_json.go
Original file line number Diff line number Diff line change
@@ -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
}
132 changes: 132 additions & 0 deletions internal/timber/todo_json_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
82 changes: 68 additions & 14 deletions internal/timber/todo_progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading