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
+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")
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()
+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
// 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)
}
}