From 36a28a40d24d10dd207d547be2e1ccc3dc65a205 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Thu, 7 May 2026 10:33:55 +0200 Subject: [PATCH] feat(go): add --dry-run to fuj sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror fuj infer's read-only mode: SyncOpts.DryRun skips WriteHeader, AppendValues, and SortByDateColumn, printing one "Dry run: would …" line per planned operation instead. ID-dedup still runs so the output reflects exactly what the next real sync would write. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 7 ++++ .../plans/2026-05-07-1033-fuj-sync-dry-run.md | 36 +++++++++++++++++++ go/cmd/fuj/main.go | 11 ++++-- go/internal/services/banksync/sync.go | 20 +++++++++-- go/internal/services/banksync/sync_test.go | 17 +++++++++ 5 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 docs/plans/2026-05-07-1033-fuj-sync-dry-run.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c315de1..e47f207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026-05-07 10:32 CEST — feat(go): --dry-run for fuj sync + +- `SyncOpts.DryRun bool` added; when true, `SyncToSheets` prints planned writes (`would write header row`, `would append date=… amount=… sender=…`, `would sort by date`) and returns without calling `WriteHeader`, `AppendValues`, or `SortByDateColumn`. +- `fuj sync --dry-run` flag wired in `cmd/fuj/main.go`; mirrors existing `fuj infer --dry-run` behaviour. +- `TestSyncToSheets_DryRun` added to banksync test suite. + ## 2026-05-07 01:03 CEST — feat(go/M4): IO layer behind interfaces - `go/internal/io/attendance`: CSV-over-public-URL client + `Fake` for both adult and junior tabs. @@ -36,6 +42,7 @@ - `scripts/infer_payments.py`: union adults + junior rosters so junior-only members are visible to the matcher. - Root cause: `get_members_with_fees()` reads only the adults sheet; junior-only kids like Jáchym Kubík were absent from `member_names`, causing the exact-match short-circuit to never fire and a different adult sharing the first name to win via fuzzy review. - Two regression tests added to `tests/test_match_members.py`. + ## 2026-05-06 16:05 CEST — feat(go/M2.10): port domain/reconcile.Reconcile - New `go/internal/domain/reconcile` package porting the three-phase payment allocation from `scripts/match_payments.py reconcile()`. diff --git a/docs/plans/2026-05-07-1033-fuj-sync-dry-run.md b/docs/plans/2026-05-07-1033-fuj-sync-dry-run.md new file mode 100644 index 0000000..24fc6c8 --- /dev/null +++ b/docs/plans/2026-05-07-1033-fuj-sync-dry-run.md @@ -0,0 +1,36 @@ +# Plan: add `--dry-run` to `fuj sync` + +## Context + +`fuj infer` already supports `--dry-run` (it builds the planned `BatchUpdateValues` +operations, prints them, and skips the actual write — see +`go/internal/services/banksync/infer.go:136-156` and the +`Dry run: would update N row(s).` line in `go/cmd/fuj/main.go:209-213`). + +`fuj sync` had no equivalent. It always committed three potential writes to the +payments sheet: `WriteHeader` (if the header row is missing/wrong), `AppendValues` +(for each new Fio transaction), and `SortByDateColumn` (if `--sort`, default true). +For inspecting what a sync *would* do — useful when debugging dedupe, sanity-checking +a date window, or wiring up the command for the first time on a new account — the +only options were pointing at a throwaway spreadsheet or reading the diff after the fact. + +This change mirrors `infer`'s read-only mode for `sync`: same flag name, same output +style, same "build the data structures, print instead of writing" shape. + +## Files modified + +1. `go/internal/services/banksync/sync.go` — `DryRun bool` field added to `SyncOpts`; three write points gated on `opts.DryRun` +2. `go/cmd/fuj/main.go` — `--dry-run` flag added to `syncCmd`; final println split on `*dryRun` +3. `go/internal/services/banksync/sync_test.go` — `TestSyncToSheets_DryRun` added +4. `CHANGELOG.md` — entry added + +## Behaviour + +When `--dry-run` is set: + +- If the sheet header is missing/wrong → prints `Dry run: would write header row`; skips `WriteHeader` +- For each non-deduped Fio transaction → prints `Dry run: would append date=… amount=… sender=… vs=… message=…`; skips `AppendValues` +- If `--sort` is true → prints `Dry run: would sort by date`; skips `SortByDateColumn` +- Returns `len(newRows)` so the caller can print `Dry run: would sync N new transaction(s).` + +The existing ID-dedup logic runs in full even during dry-run (reads the sheet, builds `existingIDs`), so the output reflects exactly what the next real sync would do. diff --git a/go/cmd/fuj/main.go b/go/cmd/fuj/main.go index 8c7a439..ebe8d0d 100644 --- a/go/cmd/fuj/main.go +++ b/go/cmd/fuj/main.go @@ -134,8 +134,9 @@ func syncCmd(args []string) { fromStr := fs.String("from", "", "start date YYYY-MM-DD") toStr := fs.String("to", "", "end date YYYY-MM-DD") sort := fs.Bool("sort", true, "sort sheet by date after appending") + dryRun := fs.Bool("dry-run", false, "print planned writes without modifying the sheet") fs.Usage = func() { - fmt.Fprintln(os.Stderr, "usage: fuj sync [--days N] [--from YYYY-MM-DD --to YYYY-MM-DD] [--sort]") + fmt.Fprintln(os.Stderr, "usage: fuj sync [--days N] [--from YYYY-MM-DD --to YYYY-MM-DD] [--sort] [--dry-run]") fs.PrintDefaults() } if err := fs.Parse(args); err != nil { @@ -153,7 +154,7 @@ func syncCmd(args []string) { } fioCli := fio.New(cfg.FioAPIToken, config.IBANAccountNum(cfg.BankAccount), nil) - opts := banksync.SyncOpts{Days: *days, Sort: *sort} + opts := banksync.SyncOpts{Days: *days, Sort: *sort, DryRun: *dryRun} if *fromStr != "" && *toStr != "" { opts.From, err = time.Parse("2006-01-02", *fromStr) if err != nil { @@ -172,7 +173,11 @@ func syncCmd(args []string) { fmt.Fprintf(os.Stderr, "fuj sync: %v\n", err) os.Exit(1) } - fmt.Printf("Synced %d new transaction(s).\n", n) + if *dryRun { + fmt.Printf("Dry run: would sync %d new transaction(s).\n", n) + } else { + fmt.Printf("Synced %d new transaction(s).\n", n) + } } func inferCmd(args []string) { diff --git a/go/internal/services/banksync/sync.go b/go/internal/services/banksync/sync.go index afec8a8..a48b63e 100644 --- a/go/internal/services/banksync/sync.go +++ b/go/internal/services/banksync/sync.go @@ -30,6 +30,7 @@ type SyncOpts struct { Days int // look-back window when From/To are zero From, To time.Time // explicit window (overrides Days) Sort bool // sort the sheet by Date after appending + DryRun bool // print planned writes without modifying the sheet } // SyncToSheets fetches Fio transactions and appends new ones to the payments sheet. @@ -52,8 +53,12 @@ func SyncToSheets( if len(rows) > 0 { header := rows[0] if !headerMatches(header) { - if err := sh.WriteHeader(ctx, spreadsheetID, columnLabels); err != nil { - return 0, fmt.Errorf("sync: write header: %w", err) + if opts.DryRun { + fmt.Println("Dry run: would write header row") + } else { + if err := sh.WriteHeader(ctx, spreadsheetID, columnLabels); err != nil { + return 0, fmt.Errorf("sync: write header: %w", err) + } } } else { for _, row := range rows[1:] { @@ -113,6 +118,17 @@ func SyncToSheets( return 0, nil } + if opts.DryRun { + for _, row := range newRows { + fmt.Printf("Dry run: would append date=%v amount=%v sender=%v vs=%v message=%v\n", + row[0], row[1], row[6], row[7], row[8]) + } + if opts.Sort { + fmt.Println("Dry run: would sort by date") + } + return len(newRows), nil + } + if err := sh.AppendValues(ctx, spreadsheetID, "A2", newRows); err != nil { return 0, fmt.Errorf("sync: append: %w", err) } diff --git a/go/internal/services/banksync/sync_test.go b/go/internal/services/banksync/sync_test.go index d7b1ca1..73f9b8e 100644 --- a/go/internal/services/banksync/sync_test.go +++ b/go/internal/services/banksync/sync_test.go @@ -127,6 +127,23 @@ func TestSyncToSheets_ExplicitDateWindow(t *testing.T) { } } +func TestSyncToSheets_DryRun(t *testing.T) { + sh := &sheets.Fake{Values: map[string][][]any{"SHEETID/A1:K": {}}} + fioFake := &fio.Fake{Transactions: testFioTxns} + + n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh, + SyncOpts{Days: 30, Sort: true, DryRun: true}) + if err != nil { + t.Fatal(err) + } + if n != 2 { + t.Errorf("want 2 planned, got %d", n) + } + if len(sh.Appended) != 0 { + t.Error("dry-run must not call AppendValues") + } +} + // syncIDFor mirrors what SyncToSheets computes for a given fio.Transaction. func syncIDFor(tx fio.Transaction) string { currency := tx.Currency