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 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 20:10:57 +02:00
co-authored by Claude Opus 5
parent ebf7770569
commit 5289250400
3 changed files with 50 additions and 41 deletions
+16 -11
View File
@@ -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. `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 `--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 rows to read: one line per distinct description, since fifty visits to the same
to the same shop are one glob, not fifty lines. Descriptions print exactly as a shop are one glob, not fifty lines. Descriptions print exactly as a glob sees
glob sees them — upper-cased with whitespace collapsed — so two statements that them — upper-cased with whitespace collapsed — so two statements that worded the
worded the same payee differently collapse into the one row they deserve, and a same payee differently collapse into the one row they deserve, and a pattern
pattern written from the list matches what the list showed you. written from the list matches what the list showed you.
``` ```
$ money ls --untagged --uniq $ money ls --untagged --uniq
ACCOUNT DESCRIPTION DESCRIPTION
checking KAUFLAND 4412 SOFIA INTEREST PAID
checking LIDL SOFIA 4412 KAUFLAND 4412 SOFIA
savings INTEREST PAID LIDL SOFIA 4412
3 distinct descriptions 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 It composes with the other filters (`--account`, `--month`, `--search`), and
`--limit` caps the rows printed, saying how many it held back. It cannot be `--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 combined with `--wide`, whose columns all belong to a single transaction.
there is nothing sensible to print for a whole group of them.
### TUI keys ### TUI keys
+24 -25
View File
@@ -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") untagged := fs.Bool("untagged", false, "only transactions no rule tagged and no transfer claimed")
limit := fs.Int("limit", 0, "maximum rows (0 = no limit)") limit := fs.Int("limit", 0, "maximum rows (0 = no limit)")
wide := fs.Bool("wide", false, "also show type and reported balance") 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 { if err := fs.Parse(args); err != nil {
return err return err
} }
if *uniq && *wide { if *uniq && *wide {
// Both add columns, but a reported balance belongs to one row and // --wide's columns all belong to a single transaction, and nothing
// nothing sensible can be printed for a whole group of them. // sensible can be printed for a whole group of them.
return fmt.Errorf("--uniq and --wide cannot be combined") return fmt.Errorf("--uniq and --wide cannot be combined")
} }
@@ -332,35 +332,34 @@ func cmdLs(root string, args []string) error {
return nil return nil
} }
// printUniq lists each account and description once, which is the shape of the // printUniq lists each description once, which is the shape of the question
// question "what still needs a rule?" — fifty visits to one shop are one // "what still needs a rule?" — fifty visits to one shop are one pattern to
// pattern to write, not fifty rows to read. // 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 // 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 // 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 // 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. // differ only in spacing or case collapse to the one row they deserve.
func printUniq(out io.Writer, txns []model.Transaction, limit int) error { func printUniq(out io.Writer, txns []model.Transaction, limit int) error {
type row struct{ account, desc string } seen := map[string]bool{}
seen := map[row]bool{} var rows []string
var rows []row
for _, t := range txns { for _, t := range txns {
r := row{t.AccountSlug, model.NormalizeDescription(t.Description)} desc := model.NormalizeDescription(t.Description)
if seen[r] { if seen[desc] {
continue continue
} }
seen[r] = true seen[desc] = true
rows = append(rows, r) rows = append(rows, desc)
} }
// Grouped by account and alphabetical within it: the same payee under // Alphabetical, so the same payee under slightly different wordings lands
// slightly different wordings then lands on adjacent lines, where one glob // on adjacent lines, where one glob covering both is easy to see.
// covering both is easy to see. sort.Strings(rows)
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
})
total := len(rows) total := len(rows)
if limit > 0 && len(rows) > limit { 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) w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ACCOUNT\tDESCRIPTION") fmt.Fprintln(w, "DESCRIPTION")
for _, r := range rows { for _, desc := range rows {
fmt.Fprintf(w, "%s\t%s\n", r.account, r.desc) fmt.Fprintln(w, desc)
} }
w.Flush() w.Flush()
+10 -5
View File
@@ -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 // The account is not part of the key. A rule matches on the description and
// account, and the two may well want different tags. // only optionally narrows to an account, so one payee seen on two accounts is
func TestUniqKeepsAccountsApart(t *testing.T) { // 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, out := uniqOutput(t, 0,
txn("checking", "TRANSFER"), txn("checking", "TRANSFER"),
txn("savings", "TRANSFER"), txn("savings", "TRANSFER"),
) )
if !strings.Contains(out, "2 distinct descriptions") { if !strings.Contains(out, "1 distinct descriptions") {
t.Errorf("want a row per account:\n%s", out) 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)
} }
} }