From 528925040000a20446ee7a151cb67c3eb39286c4 Mon Sep 17 00:00:00 2001 From: Nikola Petrov Date: Sat, 15 Aug 2026 20:10:57 +0200 Subject: [PATCH] Key ls --uniq on the description alone The listing keyed on account and description, so a payee seen on two accounts was two rows. The rule builder groups by description alone and showed one, and the two lists answer the same question -- what still needs a rule -- so the one run from the shell overstated the work left and disagreed with the one on screen. A rule matches on the description and only optionally narrows to an account, so one payee is one pattern to write however many accounts it turns up on. The account column goes with the key; --account is still how the listing is scoped to one. Co-Authored-By: Claude Opus 5 --- README.md | 27 +++++++++++++---------- cmd/money/main.go | 49 +++++++++++++++++++++--------------------- cmd/money/main_test.go | 15 ++++++++----- 3 files changed, 50 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 3319fc9..2f245cc 100644 --- a/README.md +++ b/README.md @@ -78,26 +78,31 @@ imported — creating an `account.toml` is not enough on its own. Run `money import` (or press `i` in the TUI) after adding one. `--uniq` turns `ls` into a list of patterns still to write rather than a list of -rows to read: one line per distinct account and description, since fifty visits -to the same shop are one glob, not fifty lines. Descriptions print exactly as a -glob sees them — upper-cased with whitespace collapsed — so two statements that -worded the same payee differently collapse into the one row they deserve, and a -pattern written from the list matches what the list showed you. +rows to read: one line per distinct description, since fifty visits to the same +shop are one glob, not fifty lines. Descriptions print exactly as a glob sees +them — upper-cased with whitespace collapsed — so two statements that worded the +same payee differently collapse into the one row they deserve, and a pattern +written from the list matches what the list showed you. ``` $ money ls --untagged --uniq -ACCOUNT DESCRIPTION -checking KAUFLAND 4412 SOFIA -checking LIDL SOFIA 4412 -savings INTEREST PAID +DESCRIPTION +INTEREST PAID +KAUFLAND 4412 SOFIA +LIDL SOFIA 4412 3 distinct descriptions ``` +The account is not part of what makes a row distinct. A rule matches on the +description and only optionally narrows to an account, so one payee seen on two +accounts is still one pattern to write — and it is one row in the rule builder +on `4`, which groups the same way. Pass `--account` to scope the listing +instead. + It composes with the other filters (`--account`, `--month`, `--search`), and `--limit` caps the rows printed, saying how many it held back. It cannot be -combined with `--wide`: a reported balance belongs to one transaction, and -there is nothing sensible to print for a whole group of them. +combined with `--wide`, whose columns all belong to a single transaction. ### TUI keys diff --git a/cmd/money/main.go b/cmd/money/main.go index cd781da..0febac6 100644 --- a/cmd/money/main.go +++ b/cmd/money/main.go @@ -270,13 +270,13 @@ func cmdLs(root string, args []string) error { untagged := fs.Bool("untagged", false, "only transactions no rule tagged and no transfer claimed") limit := fs.Int("limit", 0, "maximum rows (0 = no limit)") wide := fs.Bool("wide", false, "also show type and reported balance") - uniq := fs.Bool("uniq", false, "one row per distinct account and description") + uniq := fs.Bool("uniq", false, "one row per distinct description") if err := fs.Parse(args); err != nil { return err } if *uniq && *wide { - // Both add columns, but a reported balance belongs to one row and - // nothing sensible can be printed for a whole group of them. + // --wide's columns all belong to a single transaction, and nothing + // sensible can be printed for a whole group of them. return fmt.Errorf("--uniq and --wide cannot be combined") } @@ -332,35 +332,34 @@ func cmdLs(root string, args []string) error { return nil } -// printUniq lists each account and description once, which is the shape of the -// question "what still needs a rule?" — fifty visits to one shop are one -// pattern to write, not fifty rows to read. +// printUniq lists each description once, which is the shape of the question +// "what still needs a rule?" — fifty visits to one shop are one pattern to +// write, not fifty rows to read. +// +// The account is deliberately not part of the key. A rule matches on the +// description and only optionally narrows to an account, so the same payee +// seen on two accounts is still one pattern; splitting the row per account +// would overstate the work left and disagree with the rule builder's list, +// which groups the same way. Use --account to scope the listing instead. // // Descriptions are printed as rules.Engine matches them: normalised, since that // is the string a glob is actually tested against, so a pattern written from // this list behaves the way the list reads. It also means two statements that // differ only in spacing or case collapse to the one row they deserve. func printUniq(out io.Writer, txns []model.Transaction, limit int) error { - type row struct{ account, desc string } - seen := map[row]bool{} - var rows []row + seen := map[string]bool{} + var rows []string for _, t := range txns { - r := row{t.AccountSlug, model.NormalizeDescription(t.Description)} - if seen[r] { + desc := model.NormalizeDescription(t.Description) + if seen[desc] { continue } - seen[r] = true - rows = append(rows, r) + seen[desc] = true + rows = append(rows, desc) } - // Grouped by account and alphabetical within it: the same payee under - // slightly different wordings then lands on adjacent lines, where one glob - // covering both is easy to see. - sort.Slice(rows, func(i, j int) bool { - if rows[i].account != rows[j].account { - return rows[i].account < rows[j].account - } - return rows[i].desc < rows[j].desc - }) + // Alphabetical, so the same payee under slightly different wordings lands + // on adjacent lines, where one glob covering both is easy to see. + sort.Strings(rows) total := len(rows) if limit > 0 && len(rows) > limit { @@ -368,9 +367,9 @@ func printUniq(out io.Writer, txns []model.Transaction, limit int) error { } w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "ACCOUNT\tDESCRIPTION") - for _, r := range rows { - fmt.Fprintf(w, "%s\t%s\n", r.account, r.desc) + fmt.Fprintln(w, "DESCRIPTION") + for _, desc := range rows { + fmt.Fprintln(w, desc) } w.Flush() diff --git a/cmd/money/main_test.go b/cmd/money/main_test.go index 996f5cd..c180f15 100644 --- a/cmd/money/main_test.go +++ b/cmd/money/main_test.go @@ -52,15 +52,20 @@ func TestUniqNormalisesLikeAGlob(t *testing.T) { } } -// The same description on two accounts is two rows: a rule can be scoped to an -// account, and the two may well want different tags. -func TestUniqKeepsAccountsApart(t *testing.T) { +// The account is not part of the key. A rule matches on the description and +// only optionally narrows to an account, so one payee seen on two accounts is +// still one pattern to write -- and the rule builder's list groups the same +// way, which is the list this one has to agree with. +func TestUniqIgnoresTheAccount(t *testing.T) { out := uniqOutput(t, 0, txn("checking", "TRANSFER"), txn("savings", "TRANSFER"), ) - if !strings.Contains(out, "2 distinct descriptions") { - t.Errorf("want a row per account:\n%s", out) + if !strings.Contains(out, "1 distinct descriptions") { + t.Errorf("want one row for one description:\n%s", out) + } + if strings.Contains(out, "checking") || strings.Contains(out, "savings") { + t.Errorf("the account has no column here:\n%s", out) } }