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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ todo doctor --depth 3 /code # scan /code downward up to depth 3
todo detail TSK-001 # show task details + 20-line note preview
todo detail --lines 5 TSK-001 # preview first 5 lines of the note
todo detail --no-note TSK-001 # show task details without note preview
todo detail --full TSK-001 # show the complete note body
todo detail --json TSK-001 # machine-readable detail output
todo detail --json --full TSK-001 # ...with the complete note body in note_body
todo pickup TSK-001 # mark a task in progress (adds claimed date)
todo release TSK-001 # release a picked-up task back to open (drop claim)
todo complete TSK-001 # mark a task done (drops claimed)
Expand Down Expand Up @@ -156,7 +158,9 @@ Both `todo list --json` and `todo detail --json` emit stable, machine-readable J

`todo detail --json` returns a single object with fields: `id`, `status`, `status_symbol`, `priority`, `opened`, `opened_days`, `claimed`, `age_days`, `summary`, `disposition`, `note_path`, `note_exists`, `note_preview`, `note_preview_truncated`.

`claimed`, `age_days`, `note_preview`, and `note_preview_truncated` are omitted when empty or not applicable.
`todo detail --json --full` adds the complete note text in `note_body` and omits `note_preview` and `note_preview_truncated`. `--lines` is ignored when `--full` is set.

`claimed`, `age_days`, `note_preview`, `note_preview_truncated`, and `note_body` are omitted when empty or not applicable.

## Development

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v1.0.4
v1.0.5
13 changes: 11 additions & 2 deletions cmd/todo/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ type detailOptions struct {
lines int
noNote bool
asJSON bool
full bool
}

// jsonDetail is the machine-readable representation of a task detail.
Expand All @@ -347,6 +348,7 @@ type jsonDetail struct {
NoteExists bool `json:"note_exists"`
NotePreview string `json:"note_preview,omitempty"`
NotePreviewTruncated bool `json:"note_preview_truncated,omitempty"`
NoteBody string `json:"note_body,omitempty"`
}

func runDetail(cmd *cli.Command, cfg appConfig, opts detailOptions) error {
Expand All @@ -366,6 +368,7 @@ func runDetail(cmd *cli.Command, cfg appConfig, opts detailOptions) error {
Ref: ref,
Lines: lines,
NoNote: opts.noNote,
Full: opts.full,
})
if err != nil {
return exitError(err)
Expand All @@ -387,7 +390,9 @@ func runDetail(cmd *cli.Command, cfg appConfig, opts detailOptions) error {
NoteExists: res.NoteExists,
NotePreview: res.NotePreview,
}
if res.NotePreview != "" {
if opts.full && !opts.noNote {
jd.NoteBody = res.NoteBody
} else if res.NotePreview != "" {
jd.NotePreviewTruncated = res.NoteTruncated
}
if age := res.Task.AgeDays(); age >= 0 {
Expand Down Expand Up @@ -417,7 +422,11 @@ func runDetail(cmd *cli.Command, cfg appConfig, opts detailOptions) error {

if res.NoteExists {
_, _ = fmt.Fprintf(out, "note: %s\n", res.NotePath)
if res.NotePreview != "" {
if opts.full && res.NoteBody != "" {
for i, line := range strings.Split(res.NoteBody, "\n") {
_, _ = fmt.Fprintf(out, " %2d | %s\n", i+1, line)
}
} else if res.NotePreview != "" {
for i, line := range strings.Split(res.NotePreview, "\n") {
_, _ = fmt.Fprintf(out, " %2d | %s\n", i+1, line)
}
Expand Down
158 changes: 158 additions & 0 deletions cmd/todo/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,164 @@ func TestDetailJSONDisposition(t *testing.T) {
}
}

func TestDetailFullJSON(t *testing.T) {
setupGitRepo(t)

if _, _, err := runApp(t, []string{"init"}); err != nil {
t.Fatalf("init: %v", err)
}
longBody := "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21"
if _, _, err := runApp(t, []string{"add", "-n", "--kind", "work-order", "--note-content", longBody, "full task"}); err != nil {
t.Fatalf("add: %v", err)
}

out, _, err := runApp(t, []string{"detail", "TSK-001", "--json"})
if err != nil {
t.Fatalf("detail --json: %v", err)
}

var detail jsonDetail
if err := json.Unmarshal([]byte(out), &detail); err != nil {
t.Fatalf("parse detail json: %v", err)
}
if !detail.NotePreviewTruncated {
t.Errorf("NotePreviewTruncated = false, want true")
}
if detail.NoteBody != "" {
t.Errorf("NoteBody = %q, want empty without --full", detail.NoteBody)
}

out, _, err = runApp(t, []string{"detail", "TSK-001", "--json", "--full"})
if err != nil {
t.Fatalf("detail --json --full: %v", err)
}

var fullDetail jsonDetail
if err := json.Unmarshal([]byte(out), &fullDetail); err != nil {
t.Fatalf("parse detail json: %v", err)
}
wantBody := "---\nkind: work-order\n---\n\n" + longBody
if fullDetail.NoteBody != wantBody {
t.Errorf("NoteBody = %q, want %q", fullDetail.NoteBody, wantBody)
}
if fullDetail.NotePreview != "" {
t.Errorf("NotePreview = %q, want empty with --full", fullDetail.NotePreview)
}
if fullDetail.NotePreviewTruncated {
t.Errorf("NotePreviewTruncated = true, want false with --full")
}
}

func TestDetailFullHuman(t *testing.T) {
setupGitRepo(t)

if _, _, err := runApp(t, []string{"init"}); err != nil {
t.Fatalf("init: %v", err)
}
if _, _, err := runApp(t, []string{"add", "-n", "--kind", "work-order", "--note-content", "line 1\nline 2\nline 3", "human full task"}); err != nil {
t.Fatalf("add: %v", err)
}

out, _, err := runApp(t, []string{"detail", "TSK-001", "--full"})
if err != nil {
t.Fatalf("detail --full: %v", err)
}
if !strings.Contains(out, "line 3") {
t.Errorf("human --full output missing line 3: %q", out)
}
}

func TestDetailFullNoNote(t *testing.T) {
setupGitRepo(t)

if _, _, err := runApp(t, []string{"init"}); err != nil {
t.Fatalf("init: %v", err)
}
if _, _, err := runApp(t, []string{"add", "no note task"}); err != nil {
t.Fatalf("add: %v", err)
}

out, _, err := runApp(t, []string{"detail", "TSK-001", "--json", "--full"})
if err != nil {
t.Fatalf("detail --json --full: %v", err)
}

var detail jsonDetail
if err := json.Unmarshal([]byte(out), &detail); err != nil {
t.Fatalf("parse detail json: %v", err)
}
if detail.NoteBody != "" {
t.Errorf("NoteBody = %q, want empty for task with no note", detail.NoteBody)
}
if detail.NoteExists {
t.Error("NoteExists = true, want false")
}
}

func TestDetailFullNoNoteFlag(t *testing.T) {
setupGitRepo(t)

if _, _, err := runApp(t, []string{"init"}); err != nil {
t.Fatalf("init: %v", err)
}
if _, _, err := runApp(t, []string{"add", "-n", "--kind", "work-order", "--note-content", "secret", "flag task"}); err != nil {
t.Fatalf("add: %v", err)
}

out, _, err := runApp(t, []string{"detail", "TSK-001", "--json", "--full", "--no-note"})
if err != nil {
t.Fatalf("detail --json --full --no-note: %v", err)
}

var detail jsonDetail
if err := json.Unmarshal([]byte(out), &detail); err != nil {
t.Fatalf("parse detail json: %v", err)
}
if detail.NoteBody != "" {
t.Errorf("NoteBody = %q, want empty with --no-note", detail.NoteBody)
}
if detail.NotePreview != "" {
t.Errorf("NotePreview = %q, want empty with --no-note", detail.NotePreview)
}
}

func TestDetailFullSymlink(t *testing.T) {
setupGitRepo(t)

if _, _, err := runApp(t, []string{"init"}); err != nil {
t.Fatalf("init: %v", err)
}
if _, _, err := runApp(t, []string{"add", "symlink task"}); err != nil {
t.Fatalf("add: %v", err)
}

noteDir := t.TempDir()
realNote := filepath.Join(noteDir, "TSK-001.md")
if err := os.WriteFile(realNote, []byte("line 1\nline 2\nline 3"), 0o644); err != nil {
t.Fatal(err)
}
symlink := filepath.Join(".todo", "notes", "TSK-001.md")
if err := os.MkdirAll(filepath.Dir(symlink), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(realNote, symlink); err != nil {
t.Fatal(err)
}

out, _, err := runApp(t, []string{"detail", "TSK-001", "--json", "--full"})
if err != nil {
t.Fatalf("detail --json --full: %v", err)
}

var detail jsonDetail
if err := json.Unmarshal([]byte(out), &detail); err != nil {
t.Fatalf("parse detail json: %v", err)
}
if detail.NoteBody != "line 1\nline 2\nline 3" {
t.Errorf("NoteBody = %q, want symlink target content", detail.NoteBody)
}
}

func TestAddDispositionRequiresNote(t *testing.T) {
setupGitRepo(t)

Expand Down
7 changes: 7 additions & 0 deletions cmd/todo/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ func newApp() *cli.Command {
lines int
noNote bool
asJSON bool
full bool
)
return &cli.Command{
Name: "detail",
Expand All @@ -354,6 +355,11 @@ func newApp() *cli.Command {
Destination: &asJSON,
Usage: "output machine-readable JSON",
},
&cli.BoolFlag{
Name: "full",
Destination: &full,
Usage: "show the full note body instead of a preview (--lines is ignored)",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
cfg, err := requireRepoConfig()
Expand All @@ -364,6 +370,7 @@ func newApp() *cli.Command {
lines: lines,
noNote: noNote,
asJSON: asJSON,
full: full,
})
},
}
Expand Down
30 changes: 25 additions & 5 deletions internal/todo/todo.go
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,7 @@ type DetailOptions struct {
Ref string
Lines int // max note lines to preview; <=0 means unlimited
NoNote bool // skip note preview
Full bool // return the complete note body instead of a preview
}

// DetailResult is the read-only result of Detail.
Expand All @@ -1001,6 +1002,7 @@ type DetailResult struct {
Disposition Disposition // park, work-order, clear (no note), or float
NotePreview string // first Lines lines of the note, if any
NoteTruncated bool // true when more lines exist beyond the preview
NoteBody string // full note body, when Full is requested
}

// Detail returns full information about a single task plus a preview of its
Expand Down Expand Up @@ -1038,12 +1040,20 @@ func Detail(opts DetailOptions) (DetailResult, error) {
}

if !opts.NoNote && exists {
preview, truncated, err := readNotePreview(notePath, opts.Lines)
if err != nil {
return DetailResult{}, fmt.Errorf("read note: %w", err)
if opts.Full {
body, err := readNoteFull(notePath)
if err != nil {
return DetailResult{}, fmt.Errorf("read note: %w", err)
}
res.NoteBody = body
} else {
preview, truncated, err := readNotePreview(notePath, opts.Lines)
if err != nil {
return DetailResult{}, fmt.Errorf("read note: %w", err)
}
res.NotePreview = preview
res.NoteTruncated = truncated
}
res.NotePreview = preview
res.NoteTruncated = truncated
}

return res, nil
Expand Down Expand Up @@ -1087,3 +1097,13 @@ func readNotePreview(path string, lines int) (string, bool, error) {
}
return strings.Join(out, "\n"), false, nil
}

// readNoteFull returns the entire contents of the note file. It follows
// symlinks so lnk-managed notes are read transparently.
func readNoteFull(path string) (string, error) {
b, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(b), nil
}
64 changes: 64 additions & 0 deletions internal/todo/todo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,70 @@ func TestDetailDoesNotMutate(t *testing.T) {
}
}

func TestDetailFullNote(t *testing.T) {
todoPath, notesDir := writeTestTodo(t, sampleTasks)
if err := os.MkdirAll(notesDir, 0o755); err != nil {
t.Fatal(err)
}
body := "line 1\nline 2\nline 3\nline 4\nline 5"
notePath := filepath.Join(notesDir, "TSK-001.md")
if err := os.WriteFile(notePath, []byte(body), 0o644); err != nil {
t.Fatal(err)
}

res, err := Detail(DetailOptions{TodoPath: todoPath, NotesDir: notesDir, Ref: "1", Full: true})
if err != nil {
t.Fatal(err)
}
if res.NotePreview != "" {
t.Errorf("NotePreview = %q, want empty", res.NotePreview)
}
if res.NoteTruncated {
t.Error("NoteTruncated = true, want false")
}
if res.NoteBody != body {
t.Errorf("NoteBody = %q, want %q", res.NoteBody, body)
}
}

func TestDetailFullNoteTruncationStillWorks(t *testing.T) {
todoPath, notesDir := writeTestTodo(t, sampleTasks)
if err := os.MkdirAll(notesDir, 0o755); err != nil {
t.Fatal(err)
}
body := "line 1\nline 2\nline 3\nline 4\nline 5"
notePath := filepath.Join(notesDir, "TSK-001.md")
if err := os.WriteFile(notePath, []byte(body), 0o644); err != nil {
t.Fatal(err)
}

res, err := Detail(DetailOptions{TodoPath: todoPath, NotesDir: notesDir, Ref: "1", Lines: 3})
if err != nil {
t.Fatal(err)
}
if !res.NoteTruncated {
t.Error("NoteTruncated = false, want true")
}
if res.NotePreview != "line 1\nline 2\nline 3" {
t.Errorf("NotePreview = %q, want truncated preview", res.NotePreview)
}
}

func TestDetailFullNoNote(t *testing.T) {
todoPath, notesDir := writeTestTodo(t, sampleTasks)

res, err := Detail(DetailOptions{TodoPath: todoPath, NotesDir: notesDir, Ref: "1", Full: true})
if err != nil {
t.Fatal(err)
}
if res.NoteBody != "" {
t.Errorf("NoteBody = %q, want empty", res.NoteBody)
}
if res.NoteExists {
t.Error("NoteExists = true, want false")
}
}

func TestRemoveWithNoteDelete(t *testing.T) {
todoPath, notesDir := writeTestTodo(t, sampleTasks)
if err := os.MkdirAll(notesDir, 0o755); err != nil {
Expand Down
Loading