From 94e9addad4d3d43a803adeed68eacee0b141bb83 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Fri, 11 Sep 2026 10:02:34 -0500 Subject: [PATCH 1/2] feat: new list subcommand with category filters and schema pinned json output --- README.md | 64 ++++++++- cmd/park/main.go | 42 ++++++ cmd/park/main_test.go | 164 ++++++++++++++++++++++ cmd/park/park.go | 14 ++ internal/config/config.go | 12 +- internal/config/config_test.go | 34 +++++ internal/store/list.go | 162 +++++++++++++++++++++ internal/store/list_test.go | 249 +++++++++++++++++++++++++++++++++ 8 files changed, 732 insertions(+), 9 deletions(-) create mode 100644 internal/store/list.go create mode 100644 internal/store/list_test.go diff --git a/README.md b/README.md index c64348c..a7b58df 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ park init park new "revisit dashboard caching approach" \ -s "current TTL feels wrong, worth a spike" -src my-cli-tool park new "keep an eye on API rate limits" -c area +park list park assist park reclassify 1767786622-idea.md -c projects ``` @@ -124,6 +125,7 @@ The JSON output includes the canonical category enum, field kinds, the date form | `init` | create the category folders (idempotent) | | `check` | verify category folders exist (useful for automation) | | `new [title]` | park a new note (alias `add`) | +| `list [--all] [-c ] [--json]` | list parked notes grouped by category (alias `ls`) | | `assist` | open the tabbed TUI browser | | `show ` | glamour-render a note, no TUI | | `reclassify -c ` | reclassify a note (alias `recat`) | @@ -141,6 +143,57 @@ The JSON output includes the canonical category enum, field kinds, the date form | `-f, --from-file` | ingest an existing markdown file | | `--filename` | explicit file slug | +### `park list` options + +| option | purpose | +|--------|---------| +| `--all` | include categories excluded by config (for example archive) | +| `-c, --category` | only list these categories (repeatable; overrides exclusion) | +| `--json` | emit a versioned, machine-readable envelope | + +`park list` renders one block per category: an upper-cased banner followed by each note's filename and frontmatter fields. Notes within a category are ordered oldest-modified first, and categories marked `excluded` in the config are hidden until `--all` is passed or the category is named explicitly with `-c`. + +``` +INBOX +----- + +filename: ttl-spike.md +category: inbox +created: 2026-07-16 +source: terminal +synopsis: current TTL feels wrong, worth a spike + +PROJECTS +-------- + +filename: caching.md +category: projects +created: 2026-07-18 +source: my-cli-tool +synopsis: dashboard caching rework +``` + +`park list --json` flattens every group into a single `items` array, each item carrying its `category`, `path`, and RFC 3339 `modified` timestamp, wrapped in a versioned envelope: + +```json +{ + "schema_version": 1, + "items": [ + { + "filename": "ttl-spike.md", + "path": "/home/you/.config/park/_inbox/ttl-spike.md", + "category": "inbox", + "created": "2026-07-16", + "source": "terminal", + "synopsis": "current TTL feels wrong, worth a spike", + "modified": "2026-07-16T19:03:11Z" + } + ] +} +``` + +`schema_version` tracks the frontmatter contract exposed by `park schema`, so consumers can detect drift. + ### Ingestion `park new` accepts content through two non-interactive paths: @@ -244,29 +297,32 @@ Config lives at `/config` as TOML. Run `park config` to print the def ```toml default_category = "inbox" -[[categories]] +[[category]] name = "inbox" path = "~/.config/park/_inbox" key = "i" -[[categories]] +[[category]] name = "projects" path = "~/.config/park/_projects" key = "p" -[[categories]] +[[category]] name = "areas" path = "~/.config/park/_areas" key = "a" -[[categories]] +[[category]] name = "archive" path = "~/.config/park/_archive" key = "x" + excluded = true ``` Categories are fully configurable: name, storage path, and TUI hotkey. Add or remove categories to match your workflow. `default_category` is where `park new` lands notes when `-c` is omitted. +Set `excluded = true` on a category to hide it from `park list` by default; it reappears with `park list --all` or when named explicitly (`park list -c archive`). The built-in config ships with `archive` excluded. + ## Tech stack | component | library | diff --git a/cmd/park/main.go b/cmd/park/main.go index 982773a..7603a77 100644 --- a/cmd/park/main.go +++ b/cmd/park/main.go @@ -55,6 +55,8 @@ func newCommand() *cli.Command { reclassifyCategory string newCategory string schemaJSON bool + listJSON bool + listAll bool ) defaultRoot := os.Getenv("PARK_ROOT") @@ -153,6 +155,46 @@ func newCommand() *cli.Command { return nil }, }, + { + Name: "list", + Aliases: []string{"ls"}, + Usage: "list parked notes grouped by category", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "json", + Destination: &listJSON, + Usage: "output machine-readable JSON", + }, + &cli.BoolFlag{ + Name: "all", + Destination: &listAll, + Usage: "include categories excluded by config", + }, + &cli.StringSliceFlag{ + Name: "category", + Aliases: []string{"c"}, + Usage: "only list these categories (repeatable; overrides exclusion)", + }, + }, + Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { + for _, name := range cmd.StringSlice("category") { + if !cfg.HasCategory(name) { + return ctx, styledExit(fmt.Errorf("unknown category %q; valid: %s", name, strings.Join(cfg.CategoryNames(), ", ")), 2) + } + } + return ctx, nil + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + opts := store.ListOptions{ + IncludeExcluded: listAll, + Categories: cmd.StringSlice("category"), + } + if err := listPark(cfg, opts, listJSON, cmd.Root().Writer); err != nil { + return styledExit(err, 1) + } + return nil + }, + }, { Name: "new", Aliases: []string{"add"}, diff --git a/cmd/park/main_test.go b/cmd/park/main_test.go index 00108c8..cfb3398 100644 --- a/cmd/park/main_test.go +++ b/cmd/park/main_test.go @@ -2,12 +2,14 @@ package cmd import ( "context" + "encoding/json" "errors" "os" "path/filepath" "strings" "testing" + "github.com/polymorcodeus/park/schema" "github.com/urfave/cli/v3" ) @@ -152,3 +154,165 @@ func TestStyledExit(t *testing.T) { t.Errorf("error = %q, want boom message", err.Error()) } } + +// writeNote writes a minimal valid note into the given category folder. +func writeNote(t *testing.T, dir, name, category, synopsis string) { + t.Helper() + content := "---\ncategory: " + category + "\ncreated: 2026-01-01\nsource: test\nsynopsis: " + synopsis + "\n---\n\nbody\n" + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatalf("write note %q: %v", name, err) + } +} + +func TestListCommandDefaultExcludesArchive(t *testing.T) { + root := t.TempDir() + if _, _, err := runPark(t, root, "init"); err != nil { + t.Fatalf("init error = %v", err) + } + writeNote(t, filepath.Join(root, "_inbox"), "inbox-note.md", "inbox", "an inbox item") + writeNote(t, filepath.Join(root, "_archive"), "archive-note.md", "archive", "an archived item") + + out, _, err := runPark(t, root, "list") + if err != nil { + t.Fatalf("list error = %v", err) + } + if !strings.Contains(out, "inbox-note.md") { + t.Errorf("list output missing inbox note:\n%s", out) + } + if strings.Contains(out, "archive-note.md") { + t.Errorf("list output included excluded archive:\n%s", out) + } +} + +func TestListAllIncludesArchive(t *testing.T) { + root := t.TempDir() + if _, _, err := runPark(t, root, "init"); err != nil { + t.Fatalf("init error = %v", err) + } + writeNote(t, filepath.Join(root, "_archive"), "archive-note.md", "archive", "an archived item") + + out, _, err := runPark(t, root, "list", "--all") + if err != nil { + t.Fatalf("list --all error = %v", err) + } + if !strings.Contains(out, "archive-note.md") { + t.Errorf("list --all output missing archive note:\n%s", out) + } +} + +func TestListCategoryOverridesExclusion(t *testing.T) { + root := t.TempDir() + if _, _, err := runPark(t, root, "init"); err != nil { + t.Fatalf("init error = %v", err) + } + writeNote(t, filepath.Join(root, "_archive"), "archive-note.md", "archive", "an archived item") + + out, _, err := runPark(t, root, "list", "--category", "archive") + if err != nil { + t.Fatalf("list --category error = %v", err) + } + if !strings.Contains(out, "archive-note.md") { + t.Errorf("list --category archive output missing archive note:\n%s", out) + } +} + +func TestListCategoryUnknown(t *testing.T) { + _, _, err := runPark(t, t.TempDir(), "list", "--category", "bogus") + if err == nil { + t.Fatal("expected error for unknown category") + } + if got := exitCode(t, err); got != 2 { + t.Errorf("exit code = %d, want 2", got) + } +} + +func TestListJSON(t *testing.T) { + root := t.TempDir() + if _, _, err := runPark(t, root, "init"); err != nil { + t.Fatalf("init error = %v", err) + } + writeNote(t, filepath.Join(root, "_inbox"), "json-note.md", "inbox", "a json item") + + out, _, err := runPark(t, root, "list", "--json") + if err != nil { + t.Fatalf("list --json error = %v", err) + } + + var env struct { + SchemaVersion int `json:"schema_version"` + Items []struct { + Filename string `json:"filename"` + Category string `json:"category"` + Synopsis string `json:"synopsis"` + Modified string `json:"modified"` + } `json:"items"` + } + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("parse list json = %v\n%s", err, out) + } + if env.SchemaVersion != schema.SchemaVersion { + t.Errorf("schema_version = %d, want %d", env.SchemaVersion, schema.SchemaVersion) + } + if len(env.Items) != 1 { + t.Fatalf("items = %d, want 1", len(env.Items)) + } + if env.Items[0].Filename != "json-note.md" || env.Items[0].Category != "inbox" { + t.Errorf("item = %+v, want json-note.md in inbox", env.Items[0]) + } + if env.Items[0].Synopsis != "a json item" { + t.Errorf("synopsis = %q, want %q", env.Items[0].Synopsis, "a json item") + } + if env.Items[0].Modified == "" { + t.Error("modified is empty, want a timestamp") + } +} + +func TestListJSONCategoryFilter(t *testing.T) { + root := t.TempDir() + if _, _, err := runPark(t, root, "init"); err != nil { + t.Fatalf("init error = %v", err) + } + writeNote(t, filepath.Join(root, "_inbox"), "inbox-note.md", "inbox", "an inbox item") + writeNote(t, filepath.Join(root, "_archive"), "archive-note.md", "archive", "an archived item") + + out, _, err := runPark(t, root, "list", "--json", "--category", "archive") + if err != nil { + t.Fatalf("list --json --category error = %v", err) + } + + var env struct { + SchemaVersion int `json:"schema_version"` + Items []struct { + Filename string `json:"filename"` + Category string `json:"category"` + } `json:"items"` + } + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("parse list json = %v\n%s", err, out) + } + if env.SchemaVersion != schema.SchemaVersion { + t.Errorf("schema_version = %d, want %d", env.SchemaVersion, schema.SchemaVersion) + } + if len(env.Items) != 1 { + t.Fatalf("items = %d, want 1", len(env.Items)) + } + if env.Items[0].Filename != "archive-note.md" || env.Items[0].Category != "archive" { + t.Errorf("item = %+v, want archive-note.md in archive", env.Items[0]) + } +} + +func TestListAlias(t *testing.T) { + root := t.TempDir() + if _, _, err := runPark(t, root, "init"); err != nil { + t.Fatalf("init error = %v", err) + } + writeNote(t, filepath.Join(root, "_inbox"), "alias-note.md", "inbox", "an item") + + out, _, err := runPark(t, root, "ls") + if err != nil { + t.Fatalf("ls error = %v", err) + } + if !strings.Contains(out, "alias-note.md") { + t.Errorf("ls output missing note:\n%s", out) + } +} diff --git a/cmd/park/park.go b/cmd/park/park.go index cb36287..4eb4f81 100644 --- a/cmd/park/park.go +++ b/cmd/park/park.go @@ -16,6 +16,7 @@ import ( "github.com/polymorcodeus/park/internal/model" "github.com/polymorcodeus/park/internal/note" "github.com/polymorcodeus/park/internal/render" + "github.com/polymorcodeus/park/internal/store" ) // isTerminal reports whether the given file descriptor is connected to an @@ -151,6 +152,19 @@ func schemaPark(asJSON bool, w io.Writer) error { return nil } +// listPark lists parked notes grouped by category, either as plain text or as +// the versioned JSON envelope. +func listPark(cfg *config.Config, opts store.ListOptions, asJSON bool, w io.Writer) error { + groups, err := store.List(cfg, opts) + if err != nil { + return err + } + if asJSON { + return store.WriteListJSON(w, groups) + } + return store.FormatList(w, groups) +} + func assistPark(cfg *config.Config, w io.Writer) error { m, err := model.NewAssistModel(cfg) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index d9df4ec..28a2e54 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -177,11 +177,13 @@ func DefaultRootPath() string { } // Category defines a single category (inbox, project, area, archive, or -// user-defined) with its storage path and TUI hotkey. +// user-defined) with its storage path and TUI hotkey. Excluded categories are +// omitted from `park list` by default and reappear with `--all`. type Category struct { - Name string `toml:"name"` - Path string `toml:"path"` - Key string `toml:"key"` + Name string `toml:"name"` + Path string `toml:"path"` + Key string `toml:"key"` + Excluded bool `toml:"excluded,omitempty"` } // DefaultConfig returns the built-in IPAA default configuration. @@ -195,7 +197,7 @@ func DefaultConfig(root string) *Config { {Name: string(schema.CategoryInbox), Path: filepath.Join(root, "_inbox"), Key: "i"}, {Name: string(schema.CategoryProjects), Path: filepath.Join(root, "_projects"), Key: "p"}, {Name: string(schema.CategoryAreas), Path: filepath.Join(root, "_areas"), Key: "a"}, - {Name: string(schema.CategoryArchive), Path: filepath.Join(root, "_archive"), Key: "x"}, + {Name: string(schema.CategoryArchive), Path: filepath.Join(root, "_archive"), Key: "x", Excluded: true}, }, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 631ceb3..26da79b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "strings" "testing" ) @@ -165,3 +166,36 @@ func TestDefaultConfigUsesProvidedRoot(t *testing.T) { t.Errorf("DefaultConfig path = %q, want %q", cfg.Categories[0].Path, "/custom/root/_inbox") } } + +func TestDefaultConfigExcludesArchive(t *testing.T) { + cfg := DefaultConfig("/tmp/park") + + archive, ok := cfg.CategoryByName("archive") + if !ok { + t.Fatal("archive category missing from default config") + } + if !archive.Excluded { + t.Error("archive Excluded = false, want true") + } + + for _, name := range []string{"inbox", "projects", "areas"} { + cat, _ := cfg.CategoryByName(name) + if cat.Excluded { + t.Errorf("%s Excluded = true, want false", name) + } + } +} + +func TestDumpIncludesExcluded(t *testing.T) { + cfg := DefaultConfig("/tmp/park") + out, err := cfg.Dump() + if err != nil { + t.Fatalf("Dump() error = %v", err) + } + if !strings.Contains(out, "excluded = true") { + t.Errorf("Dump() missing excluded marker:\n%s", out) + } + if strings.Contains(out, "excluded = false") { + t.Errorf("Dump() emitted excluded = false for a non-excluded category:\n%s", out) + } +} diff --git a/internal/store/list.go b/internal/store/list.go new file mode 100644 index 0000000..a629b8d --- /dev/null +++ b/internal/store/list.go @@ -0,0 +1,162 @@ +package store + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/polymorcodeus/park/internal/config" + "github.com/polymorcodeus/park/schema" +) + +// ListOptions controls which categories List scans. +type ListOptions struct { + // IncludeExcluded includes categories marked Excluded in the config. It is + // ignored when Categories is set, since naming a category explicitly + // overrides its excluded flag. + IncludeExcluded bool + // Categories restricts the listing to the named categories. When empty, + // every category except those marked Excluded is listed. Naming a + // category explicitly overrides its excluded flag. + Categories []string +} + +// Group is the set of notes in a single category. +type Group struct { + Category string + Items []Item +} + +// selectedCategories resolves the categories to scan, in config order and +// deduplicated. It returns an error naming the first unknown category in +// opts.Categories. +func selectedCategories(cfg *config.Config, opts ListOptions) ([]config.Category, error) { + if len(opts.Categories) == 0 { + var out []config.Category + for _, cl := range cfg.Categories { + if cl.Excluded && !opts.IncludeExcluded { + continue + } + out = append(out, cl) + } + return out, nil + } + + want := make(map[string]struct{}, len(opts.Categories)) + for _, name := range opts.Categories { + if _, ok := cfg.CategoryByName(name); !ok { + return nil, fmt.Errorf("unknown category %q; valid: %s", name, strings.Join(cfg.CategoryNames(), ", ")) + } + want[name] = struct{}{} + } + + var out []config.Category + for _, cl := range cfg.Categories { + if _, ok := want[cl.Name]; ok { + out = append(out, cl) + } + } + return out, nil +} + +// List returns parked notes grouped by category according to opts. Groups +// preserve config category order and empty groups are omitted; notes within a +// group are ordered by Scan (oldest modified first). +func List(cfg *config.Config, opts ListOptions) ([]Group, error) { + cats, err := selectedCategories(cfg, opts) + if err != nil { + return nil, err + } + + var groups []Group + for _, cl := range cats { + items, scanErr := Scan(cfg, cl.Name) + if scanErr != nil { + return nil, scanErr + } + if len(items) == 0 { + continue + } + groups = append(groups, Group{Category: cl.Name, Items: items}) + } + return groups, nil +} + +// FormatList writes the human-readable listing in a frontmatter-like layout: an +// upper-cased category banner followed by one block per note listing its +// filename and frontmatter fields. Groups with no notes are omitted, and a +// listing with nothing to show prints a single notice. +func FormatList(w io.Writer, groups []Group) error { + if len(groups) == 0 { + if _, err := fmt.Fprintln(w, "No notes found."); err != nil { + return fmt.Errorf("write list output: %w", err) + } + return nil + } + + for i, g := range groups { + header := strings.ToUpper(g.Category) + rule := strings.Repeat("-", len(header)) + if _, err := fmt.Fprintf(w, "%s\n%s\n", header, rule); err != nil { + return fmt.Errorf("write list output: %w", err) + } + for _, it := range g.Items { + if _, err := fmt.Fprintf(w, "\nfilename: %s\ncategory: %s\ncreated: %s\nsource: %s\nsynopsis: %s\n", + it.Filename, g.Category, it.Created, it.Source, it.Synopsis); err != nil { + return fmt.Errorf("write list output: %w", err) + } + } + if i < len(groups)-1 { + if _, err := fmt.Fprintln(w); err != nil { + return fmt.Errorf("write list output: %w", err) + } + } + } + return nil +} + +// ListItem is the machine-readable representation of one parked note. +type ListItem struct { + Filename string `json:"filename"` + Path string `json:"path"` + Category string `json:"category"` + Created string `json:"created"` + Source string `json:"source"` + Synopsis string `json:"synopsis"` + Modified string `json:"modified"` +} + +// ListEnvelope is the versioned JSON contract for `park list --json`. +type ListEnvelope struct { + SchemaVersion int `json:"schema_version"` + Items []ListItem `json:"items"` +} + +// WriteListJSON writes the notes as a flat, versioned JSON envelope. Every +// group is flattened into a single items array and each item carries its +// category. Items is always emitted as an array, never null. +func WriteListJSON(w io.Writer, groups []Group) error { + items := make([]ListItem, 0) + for _, g := range groups { + for _, it := range g.Items { + items = append(items, ListItem{ + Filename: it.Filename, + Path: it.Path, + Category: g.Category, + Created: it.Created, + Source: it.Source, + Synopsis: it.Synopsis, + Modified: it.ModTime.UTC().Format(time.RFC3339), + }) + } + } + + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(ListEnvelope{SchemaVersion: schema.SchemaVersion, Items: items}); err != nil { + return fmt.Errorf("encode list json: %w", err) + } + return nil +} diff --git a/internal/store/list_test.go b/internal/store/list_test.go new file mode 100644 index 0000000..0a20ddf --- /dev/null +++ b/internal/store/list_test.go @@ -0,0 +1,249 @@ +package store + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/polymorcodeus/park/internal/config" + "github.com/polymorcodeus/park/internal/note" + "github.com/polymorcodeus/park/schema" +) + +// createNote parks a note in the given category and returns its filename. +func createNote(t *testing.T, cfg *config.Config, filename, category, synopsis string) string { + t.Helper() + path, err := note.Create(cfg, note.Draft{ + Filename: filename, + Metadata: note.Metadata{Synopsis: synopsis, Source: "test", Category: category}, + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + return path +} + +func TestListExcludesArchiveByDefault(t *testing.T) { + tmp := t.TempDir() + cfg := config.DefaultConfig(tmp) + if _, _, err := Init(cfg); err != nil { + t.Fatalf("Init() error = %v", err) + } + + createNote(t, cfg, "inbox-note", "inbox", "inbox item") + createNote(t, cfg, "archive-note", "archive", "archive item") + + groups, err := List(cfg, ListOptions{}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(groups) != 1 { + t.Fatalf("List() returned %d groups, want 1", len(groups)) + } + if groups[0].Category != "inbox" { + t.Errorf("group category = %q, want inbox", groups[0].Category) + } +} + +func TestListIncludeExcluded(t *testing.T) { + tmp := t.TempDir() + cfg := config.DefaultConfig(tmp) + if _, _, err := Init(cfg); err != nil { + t.Fatalf("Init() error = %v", err) + } + + createNote(t, cfg, "inbox-note", "inbox", "inbox item") + createNote(t, cfg, "archive-note", "archive", "archive item") + + groups, err := List(cfg, ListOptions{IncludeExcluded: true}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(groups) != 2 { + t.Fatalf("List() returned %d groups, want 2", len(groups)) + } + if groups[0].Category != "inbox" || groups[1].Category != "archive" { + t.Errorf("groups = %q, %q; want inbox, archive (config order)", groups[0].Category, groups[1].Category) + } +} + +func TestListExplicitCategoryOverridesExclusion(t *testing.T) { + tmp := t.TempDir() + cfg := config.DefaultConfig(tmp) + if _, _, err := Init(cfg); err != nil { + t.Fatalf("Init() error = %v", err) + } + + createNote(t, cfg, "archive-note", "archive", "archive item") + + groups, err := List(cfg, ListOptions{Categories: []string{"archive"}}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(groups) != 1 || groups[0].Category != "archive" { + t.Fatalf("List() = %+v, want a single archive group", groups) + } +} + +func TestListUnknownCategory(t *testing.T) { + tmp := t.TempDir() + cfg := config.DefaultConfig(tmp) + + _, err := List(cfg, ListOptions{Categories: []string{"nope"}}) + if err == nil { + t.Fatal("expected error for unknown category") + } + if !strings.Contains(err.Error(), "unknown category") { + t.Errorf("error = %q, want unknown-category message", err.Error()) + } +} + +func TestListOmitsEmptyCategories(t *testing.T) { + tmp := t.TempDir() + cfg := config.DefaultConfig(tmp) + if _, _, err := Init(cfg); err != nil { + t.Fatalf("Init() error = %v", err) + } + + createNote(t, cfg, "area-note", "areas", "area item") + + groups, err := List(cfg, ListOptions{}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(groups) != 1 || groups[0].Category != "areas" { + t.Fatalf("List() = %+v, want a single areas group", groups) + } +} + +func TestFormatList(t *testing.T) { + tmp := t.TempDir() + cfg := config.DefaultConfig(tmp) + if _, _, err := Init(cfg); err != nil { + t.Fatalf("Init() error = %v", err) + } + createNote(t, cfg, "first-note", "inbox", "a synopsis") + + groups, err := List(cfg, ListOptions{}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + + var b bytes.Buffer + if err := FormatList(&b, groups); err != nil { + t.Fatalf("FormatList() error = %v", err) + } + out := b.String() + for _, want := range []string{ + "INBOX\n-----\n", + "filename: first-note.md\n", + "category: inbox\n", + "source: test\n", + "synopsis: a synopsis\n", + } { + if !strings.Contains(out, want) { + t.Errorf("FormatList() output missing %q:\n%s", want, out) + } + } +} + +func TestFormatListSeparatesCategories(t *testing.T) { + tmp := t.TempDir() + cfg := config.DefaultConfig(tmp) + if _, _, err := Init(cfg); err != nil { + t.Fatalf("Init() error = %v", err) + } + createNote(t, cfg, "inbox-note", "inbox", "inbox item") + createNote(t, cfg, "projects-note", "projects", "projects item") + + groups, err := List(cfg, ListOptions{}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + + var b bytes.Buffer + if err := FormatList(&b, groups); err != nil { + t.Fatalf("FormatList() error = %v", err) + } + out := b.String() + if !strings.Contains(out, "\n\nPROJECTS\n--------\n") { + t.Errorf("FormatList() should separate categories with a blank line:\n%s", out) + } + if strings.Contains(out, "\n--------\nPROJECTS") { + t.Errorf("FormatList() emitted a top divider rule:\n%s", out) + } +} + +func TestFormatListEmpty(t *testing.T) { + var b bytes.Buffer + if err := FormatList(&b, nil); err != nil { + t.Fatalf("FormatList() error = %v", err) + } + if got := strings.TrimSpace(b.String()); got != "No notes found." { + t.Errorf("FormatList() = %q, want %q", got, "No notes found.") + } +} + +func TestWriteListJSON(t *testing.T) { + tmp := t.TempDir() + cfg := config.DefaultConfig(tmp) + if _, _, err := Init(cfg); err != nil { + t.Fatalf("Init() error = %v", err) + } + createNote(t, cfg, "json-note", "inbox", "json synopsis") + + groups, err := List(cfg, ListOptions{}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + + var b bytes.Buffer + if err := WriteListJSON(&b, groups); err != nil { + t.Fatalf("WriteListJSON() error = %v", err) + } + + var env ListEnvelope + if err := json.Unmarshal(b.Bytes(), &env); err != nil { + t.Fatalf("parse JSON = %v\n%s", err, b.String()) + } + if env.SchemaVersion != schema.SchemaVersion { + t.Errorf("schema_version = %d, want %d", env.SchemaVersion, schema.SchemaVersion) + } + if len(env.Items) != 1 { + t.Fatalf("items = %d, want 1", len(env.Items)) + } + item := env.Items[0] + if item.Filename != "json-note.md" { + t.Errorf("filename = %q, want json-note.md", item.Filename) + } + if item.Category != "inbox" { + t.Errorf("category = %q, want inbox", item.Category) + } + if item.Source != "test" { + t.Errorf("source = %q, want test", item.Source) + } + if item.Synopsis != "json synopsis" { + t.Errorf("synopsis = %q, want json synopsis", item.Synopsis) + } + if item.Created == "" { + t.Error("created is empty, want a date") + } + if _, err := time.Parse(time.RFC3339, item.Modified); err != nil { + t.Errorf("modified = %q, not RFC3339: %v", item.Modified, err) + } + if !strings.HasSuffix(item.Modified, "Z") { + t.Errorf("modified = %q, want UTC (Z suffix)", item.Modified) + } +} + +func TestWriteListJSONEmpty(t *testing.T) { + var b bytes.Buffer + if err := WriteListJSON(&b, nil); err != nil { + t.Fatalf("WriteListJSON() error = %v", err) + } + if !strings.Contains(b.String(), `"items": []`) { + t.Errorf("empty JSON = %s, want an empty items array", b.String()) + } +} From 65ad252808eadb856a5302f0eda7bd5f3effb825 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Fri, 11 Sep 2026 10:06:15 -0500 Subject: [PATCH 2/2] chore: version bump --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 5aff472..b043aa6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.4.1 +v0.5.0