From ea54a96aa4386fd38e76c4c6018a8f823df1af1d Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Thu, 24 Sep 2026 10:51:24 -0500 Subject: [PATCH] feat: added --full to detail --- README.md | 6 +- VERSION | 2 +- cmd/todo/actions.go | 13 ++- cmd/todo/cli_test.go | 158 +++++++++++++++++++++++++++++++++++++ cmd/todo/cmd.go | 7 ++ internal/todo/todo.go | 30 +++++-- internal/todo/todo_test.go | 64 +++++++++++++++ 7 files changed, 271 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 08b9045..014c411 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/VERSION b/VERSION index 3e7bcf0..80e0d76 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.4 +v1.0.5 diff --git a/cmd/todo/actions.go b/cmd/todo/actions.go index f7fcd15..da08cc6 100644 --- a/cmd/todo/actions.go +++ b/cmd/todo/actions.go @@ -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. @@ -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 { @@ -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) @@ -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 { @@ -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) } diff --git a/cmd/todo/cli_test.go b/cmd/todo/cli_test.go index db7086d..8964b2f 100644 --- a/cmd/todo/cli_test.go +++ b/cmd/todo/cli_test.go @@ -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) diff --git a/cmd/todo/cmd.go b/cmd/todo/cmd.go index d1cbf2b..ca58791 100644 --- a/cmd/todo/cmd.go +++ b/cmd/todo/cmd.go @@ -332,6 +332,7 @@ func newApp() *cli.Command { lines int noNote bool asJSON bool + full bool ) return &cli.Command{ Name: "detail", @@ -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() @@ -364,6 +370,7 @@ func newApp() *cli.Command { lines: lines, noNote: noNote, asJSON: asJSON, + full: full, }) }, } diff --git a/internal/todo/todo.go b/internal/todo/todo.go index 57bb10e..fe3cb74 100644 --- a/internal/todo/todo.go +++ b/internal/todo/todo.go @@ -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. @@ -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 @@ -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 @@ -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 +} diff --git a/internal/todo/todo_test.go b/internal/todo/todo_test.go index 07428d0..5194d67 100644 --- a/internal/todo/todo_test.go +++ b/internal/todo/todo_test.go @@ -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 {