Visitar URL original
feat(returns): opt-in per-group breakdown for report returns (--by-group) by robcohen · Pull Request #1841 · rustledger/rustledger · GitHub
Skip to content

feat(returns): opt-in per-group breakdown for report returns (--by-group) - #1841

Merged
robcohen merged 13 commits into
mainfrom
feat/returns-per-group
Jul 21, 2026
Merged

robcohen merged 13 commits into
mainfrom
feat/returns-per-group

Conversation

@robcohen

@robcohen robcohen commented Jul 21, 2026 •

Copy link
Copy Markdown
Member

What

Adds an opt-in per-group breakdown to rledger report returns (#1820). Tag open directives with returns-group: "Name" and pass --by-group:

2020-01-01 open Assets:Broker:AAPL
  returns-group: "Tech"
2020-01-01 open Income:Dividends
  returns-group: "Tech"     ; makes Tech dividend-inclusive
Group                                 MWR      TWR    Invested     Current
------------------------------------------------------------------------
Bonds                              10.00%   10.00%         500         550
Tech                               32.36%   32.60%        1000        1300
------------------------------------------------------------------------
TOTAL                              24.85%   24.98%        1500        1850

Each group is an independent sub-portfolio: its return is computed over just that group's accounts, exactly as if you'd narrowed --investments/--income to them. This is the model beangrow and hledger roi use. It follows the prior-art direction (declared groups that bundle their income accounts, so the return is dividend-inclusive — declared in the ledger, not beangrow's external config file).

Design: groups are reported alongside the total, not as a partition of it

An earlier revision promised the group rows summed to the TOTAL (an (ungrouped) residual). A deep review proved that guarantee unsound: when two groups draw on a shared in-scope account (a pooled settlement-cash account under --investments — the common brokerage shape), the boundary flow into that shared account can't be attributed to one group. This is the same un-attributability that made us drop --by-account. So:

  • No (ungrouped) residual. Only tagged accounts appear; an untagged in-scope holding is omitted from the breakdown but still counted in the TOTAL. The TOTAL is the whole-portfolio figure, reported for reference — not claimed as the sum of the rows.
  • Warnings (stderr), not silent misreporting, for the cases a reader would misread:
    • a group that is not self-contained (shares an in-scope account with the rest of the portfolio — its return then counts an internal transfer as a flow);
    • two groups whose accounts overlap by prefix (a tagged parent or duplicate open values the same holding in both);
    • a returns-group: tag on an out-of-scope account (Equity/Liability, or outside --investments/--income) — dropped, never valued as a phantom holding;
    • a non-string tag value.
  • Why not auto per-account/per-commodity: a dividend booked to a shared cash/income account can't be attributed to one holding automatically — which is exactly why every reference tool uses declared groups.

Tests

Independent-groups (no residual; TOTAL ≠ sum of rows), self-contained warning, out-of-scope + non-string tag warnings, income-only (empty-investment) group, grouped text path, and a positively-pinned default-shape guard. Plus the engine's existing suite and the two drift-guard unit tests.

Deferred (per #1820)

True per-commodity attribution within a single account, commodity-directive tagging, and the N+1-extraction perf pass (#1832).

Part of #1820.

🤖 Generated with Claude Code

Adds two ways to break `rledger report returns` down per group, alongside the
whole-scope TOTAL. Prior art (beangrow, fava-portfolio-summary) converges on
user-declared groups that bundle their income accounts so returns are
dividend-inclusive; this follows that, declared in the ledger rather than an
external config file (beangrow's anti-pattern).

- returns-group: metadata (default when present): tag `open` directives with
  `returns-group: "Name"`. A group's members are partitioned by account TYPE
  (via AccountTypes) — income-statement accounts form the group's income
  scope, balance-sheet accounts its investment scope — so a group that tags
  its dividend account reports a DIVIDEND-INCLUSIVE return. This is the
  beangrow group model, in-ledger.
- --by-account: one row per investment account with no income scope, a price
  return (ex-dividend); a quick per-holding view without tagging. Ignored when
  returns-group: groups are declared.

Both render per-group rows + a TOTAL in text/json/csv; with neither, the
report is unchanged (the whole-scope summary), so existing behavior and tests
are preserved. Refactored the computation into a shared compute_group helper.

Tests: metadata grouping (Tech dividend-inclusive 32.36% via its tagged income
account, Bonds 10%, TOTAL) and --by-account per-account rows. 9 returns
integration tests.

Deferred (per #1820): true per-commodity attribution within a single account,
and commodity-directive tagging.
@robcohen
robcohen requested a review from a team as a code owner July 21, 2026 12:12
Copilot AI review requested due to automatic review settings July 21, 2026 12:12
@github-actions

github-actions Bot commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

Tool Time Memory
rustledger 37.4ms ± 1.7ms 23.8 MB
beancount 791.2ms ± 4.3ms 50.6 MB
Comparison 21.2x faster 2.1x less

vs main branch (2026-07-21)

Metric main this PR Change
Time 33.5ms 37.4ms +11.6% 🔴
10K transactions · 5 runs, 2 warmup · hyperfine
What do these numbers mean?
  • Time: Wall-clock time to parse and validate the ledger (± std dev)
  • Memory: Peak resident set size (RSS)
  • vs main: Compared to latest nightly benchmark on main branch

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds optional multi-row breakdowns to rledger report returns (CLI consumer of rustledger-returns) so users can see per-group/per-account performance alongside the whole-scope TOTAL.

Changes:

  • Implemented grouping by returns-group: metadata on open directives (dividend-inclusive by grouping income-statement accounts into the group’s income scope).
  • Added --by-account mode to emit one row per investment account (ex-dividend), when no metadata groups exist.
  • Extended integration tests to cover grouped JSON output for both metadata grouping and --by-account.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
crates/rustledger/tests/report_returns_test.rs Adds fixtures + JSON assertions for metadata-group and --by-account breakdowns.
crates/rustledger/src/cmd/report_cmd/returns.rs Refactors computation into compute_group, adds group discovery/rendering logic, and introduces grouped output formats.
crates/rustledger/src/cmd/report_cmd/mod.rs Wires --by-account CLI flag through to the returns report implementation.
crates/rustledger-returns/README.md Updates feature checklist/docs to reflect the new CLI breakdown behavior and remaining follow-ups.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@github-actions

Copy link
Copy Markdown
Contributor

✅ Compatibility Test Results

Metric Result
Check Compatibility 100%
BQL Compatibility 100%
Full AST Match 100%
Total Files 812
What's tested
  • Check: Exit codes and error counts match bean-check
  • BQL: Query results match bean-query
  • Files: ~800 test files downloaded from 10+ beancount repositories

Full results available in workflow artifacts

@codecov

codecov Bot commented Jul 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.61850% with 29 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/rustledger/src/cmd/report_cmd/returns.rs 90.90% 29 Missing ⚠️

📢 Thoughts on this report? Let us know!

…nciling residual

Reworks the #1820 per-group breakdown per deep-review findings on #1841.

- Drop `--by-account`: per-account auto-attribution is unsound (a dividend
  booked to a shared cash/income account cannot be attributed to one holding),
  and it wrongly booked the dividend as a contribution.
- Grouping is now opt-in behind `--by-group`; merely having `returns-group:`
  metadata no longer silently switches the output schema.
- Groups are built by classifying each tagged `open` against the whole scope,
  so they reconcile with the TOTAL: an out-of-scope tag (External / Equity /
  Liability) is ignored with a warning instead of being valued as a holding,
  and in-scope holdings left untagged collect into an `(ungrouped)` residual —
  every in-scope holding appears in exactly one row.
- A non-string `returns-group:` value warns instead of being silently dropped.

Tests: --by-group dividend-inclusive grouping, ungrouped residual +
TOTAL reconciliation, and default-is-single-summary (grouping opt-in).
@robcohen robcohen changed the title feat(returns): per-group breakdown for report returns (returns-group: metadata + --by-account) feat(returns): opt-in per-group breakdown for report returns (--by-group) Jul 21, 2026
@robcohen

Copy link
Copy Markdown
Member Author

Rework — deep-review findings addressed

Reworked the grouping design per the deep review (2 decisions confirmed with @robcohen: drop --by-account; make grouping an opt-in flag with an ungrouped residual). Mapping to the 6 findings:

# Finding Resolution
1 --by-account with empty income wrongly booked a dividend-into-holding as a contribution (invested 1020) --by-account dropped — per-account auto-attribution is unsound
2 Metadata groups classified independently of --investments, so they didn't reconcile with TOTAL Groups now classified against the whole reporting scope; untagged in-scope holdings collect into an (ungrouped) residual → rows sum to TOTAL
3 Equity/Liability tagged → routed into the investment scope, valued as holdings An out-of-scope tag (External / Equity / Liability) is ignored with a warning
4 Merely having returns-group: metadata silently switched the output schema Grouping is opt-in behind --by-group; default output shape unchanged
5 A non-string returns-group: value was silently dropped Now warns
6 N+1 extractions per group (perf) Deferred to #1832 (the twr O(n) pass), noted there

New/changed tests: --by-group dividend-inclusive grouping, (ungrouped) residual + TOTAL reconciliation (invested 1000+500=1500, current 1300+550=1850), and default-is-single-summary. Gauntlet green (clippy/doc/fmt/tests).

robcohen added 2 commits July 21, 2026 13:26
The grouped text table appended a literal `%` to every rate column, so an
undefined MWR/TWR rendered as `n/a%` — reading like a real percentage and
inconsistent with the single-scope output, where the `%` hangs off the value.

Add `fmt_rate_pct` (value carries its own `%`; `None` -> `n/a`) for the text
row, matching render_single. CSV/JSON keep the bare numeric via `fmt_rate`.
…und reconciliation

A deep review showed the "(ungrouped) residual + rows sum to TOTAL" model is not
sound: per-sub-scope extraction reclassifies whole-scope-internal flows as
phantom boundary flows, so groups sharing an in-scope account (e.g. pooled
settlement cash) do not reconcile — and prefix-matched parent/duplicate opens
double-count holdings. Reconciliation is fundamentally impossible when a flow
enters a shared account that funds more than one group (the same un-attributability
that made us drop --by-account).

Switch to the model beangrow and hledger roi use: each `returns-group:` group is
an independent sub-portfolio, reported alongside — not as a partition of — the
whole-portfolio TOTAL.

- Drop the `(ungrouped)` residual; only tagged accounts appear (untagged in-scope
  holdings are omitted from the breakdown but still counted in the TOTAL). This
  also removes the "(ungrouped)"-label collision.
- Warn (stderr), rather than silently misreport, for the cases a reader would
  misread: a group that is not self-contained (shares an in-scope account with
  the rest of the portfolio), two groups whose accounts overlap by prefix, an
  out-of-scope tag, and a non-string tag value.
- Docs/README/flag help rewritten; the TOTAL is documented as the whole-portfolio
  figure, not the sum of the rows.

Tests: independent-groups (no residual), self-contained warning, out-of-scope +
non-string tag warnings, income-only (empty-investment) group, grouped text
path, and a positively-pinned default-shape guard.
@robcohen

Copy link
Copy Markdown
Member Author

Second deep review → reworked to independent sub-portfolios

A high-effort review found the "(ungrouped) residual + rows reconcile with TOTAL" model was unsound, and I reproduced the two architectural cases against the real binary before acting:

Finding 1 — shared in-scope cash (common brokerage shape): with Assets:Broker:Cash under --investments, per-sub-scope extraction counts the pooled cash's funding against every group → invested rows summed to 3000 vs a 1500 TOTAL. Fundamental: a boundary flow into a shared account can't be attributed to one group (same un-attributability that killed --by-account).

Finding 4 — prefix overlap: an opened parent Assets:Broker prefix-matched its tagged children, so (ungrouped) re-counted the whole portfolio.

Decision (confirmed): drop the reconciliation promise; present each group as an independent sub-portfolio (the beangrow / hledger roi model). Mapping of all 9 findings:

# Finding Resolution
1 Shared in-scope cash → phantom flows, no reconciliation Groups are now independent sub-portfolios; a non-self-contained group warns naming the shared account
2 Membership from opens but TOTAL from postings → posted-but-unopened vanishes Dissolved: only tagged accounts appear; no residual claiming to cover the scope
3 Duplicate opens, different tags → double count Prefix-overlap warning (covers exact-duplicate + ancestor cases)
4 Parent-prefix open swallows children Untagged-parent case dissolved (no residual); tagged-parent overlap warns
5 (ungrouped) label collision Dissolved: residual removed
6 Text --by-group path untested Added by_group_text_output_renders_rows_and_total
7 No warning-path / empty-investment-group tests Added self-contained, out-of-scope, non-string, and income-only-group tests
8 N+1 extraction Deferred to #1832 (noted)
9 Weak !contains("groups") default test Now positively pins the single-summary schema

Pushed in 2211d2e3. Gauntlet green (clippy/doc/fmt; 14 integration + 3 unit tests). The TOTAL row is now documented as the whole-portfolio figure, reported alongside the groups rather than as their sum.

…ge cases

Third-review polish on the --by-group breakdown:

- Income-only (empty-investment) group reported a fabricated 0.00% TWR: twr()'s
  unit-chaining returns Some(0.0) over an empty position. A scope that never held
  investment capital has no holding period to weight, so TWR is now None (n/a),
  matching the already-None MWR.
- Grouped text table: the =/- rules were 72 columns but the data/header rows are
  74, so the last column overhung the underline. Derive the rule width from the
  column layout.
- Sanitize control characters in a group label before the text table (a newline
  in a quoted returns-group value could split a row / inject a line). JSON and
  CSV already escape it.
- Warn when a group is named "TOTAL" (collides with the whole-portfolio row in
  text/CSV output; JSON keeps them structurally separate).

Tests: assert income-only TWR is null; a new cross-group prefix-overlap warning
test; and pin that a self-contained-warned group still renders on stdout.
Copilot AI review requested due to automatic review settings July 21, 2026 14:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

…sum note

Round out the --by-group text output for parity and clarity (third-review UX
findings; warnings stay on stderr per the independent-sub-portfolio model):

- Add the Distributions column to the grouped text table, matching the
  single-scope output which already shows it (still under 80 columns).
- Footnote the table: "TOTAL is the whole portfolio, not the sum of the groups",
  so the TOTAL row below the rule isn't misread as a subtotal.

Test asserts both the Distributions column and the footnote are present.
Copilot AI review requested due to automatic review settings July 21, 2026 14:45
@robcohen

Copy link
Copy Markdown
Member Author

Third deep review — polish (reconciliation issues did not recur)

A high-effort review of the reworked design found 10 findings (1 refuted). None re-surfaced the reconciliation problems — the independent-sub-portfolio model holds. Resolutions:

# Finding Resolution
F2 Income-only group reported a fabricated 0.00% TWR (twr() returns Some(0.0) over an empty position) TWR is None/n/a when a scope never held capital (0fa6d3ca); test asserts it
F7 Text table rules were 72 cols but rows 74 → last column overhung Rule width derived from the column layout
F5 A newline in a returns-group: value could split/inject a text row Control chars sanitized in the text label (JSON/CSV already escape)
F4 A group named TOTAL collides with the total row in text/CSV Warns (JSON keeps them structurally separate)
F9 Prefix-overlap warning untested Added by_group_warns_on_cross_group_prefix_overlap
F10 Self-contained test discarded stdout Now asserts the warned group still renders
F6 Grouped text dropped the Distributions column Restored for parity with single-scope (5ba3f5f7)
F8 TOTAL row read like a subtotal Footnote: "TOTAL is the whole portfolio, not the sum of the groups"
F1/F3 Overlap / not-self-contained warnings are stderr-only Kept stderr-only by design: for an independent sub-portfolio the figure is a legitimate standalone view with a caveat, so a per-format caveats schema field would be disproportionate
— (refuted) CSV per-row vs JSON top-level as_of/currency Acceptable per-format shaping, not a defect

Gauntlet green (clippy/doc/fmt; 15 integration + 3 unit tests). This is the third review pass; the reconciliation model has been stable across the last two.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

crates/rustledger/src/cmd/report_cmd/returns.rs:363

  • Warnings emitted for malformed/overlapping returns-group metadata can include user-controlled group names. Since group names can contain control characters (including newlines), eprintln!("warning: {w}") can be used for terminal/log injection (spoofing extra warning lines). Consider sanitizing control characters before printing warnings to stderr (similar to what truncate() does for the text table).
    // Grouping is opt-in; warnings (bad tags, overlaps, non-self-contained
    // groups) go to stderr so they never pollute the report on stdout.
    let group_scopes = build_groups(directives, &whole_scope, &mut |w| eprintln!("warning: {w}"));
    if group_scopes.is_empty() {

…l-char labels

Fourth-review correctness findings:

- --by-group produced two incompatible schemas: with no in-scope returns-group
  metadata it fell back to the single-summary object (no groups/total keys), so a
  JSON/CSV consumer had to branch on ledger content. It now always emits the
  grouped schema (empty `groups` array plus `total`); the "no groups" notice
  stays on stderr.
- A returns-group value carrying a raw control byte (e.g. U+001B) was written raw
  inside the JSON string, because report's `json_escape` aliased `escape_string`
  (the beancount-source escaper, which only handles " \ \n \t \r). That is
  invalid JSON. Add a proper `escape_json` to rustledger-core (RFC 8259: \b \f
  and \uXXXX for the rest) and route report's json_escape through it — fixes every
  report JSON surface (holdings/accounts/income/journal/returns), not just groups.
- Broaden the text-label sanitizer to also neutralize U+2028/U+2029 (line and
  paragraph separators), which `is_control()` misses but a pager may honor.

Tests: escape_json unit test; --by-group-with-no-tags grouped-schema test; and a
control-char-label valid-JSON test.

Deferred (tool-wide, not returns-specific): CSV formula-injection neutralization
in the shared escape_csv; numeric-column overflow and Unicode display-width
alignment in fixed-width tables; the second per-group directive scan (-> #1832).
Copilot AI review requested due to automatic review settings July 21, 2026 15:14
@robcohen

Copy link
Copy Markdown
Member Author

Fourth deep review — two real correctness bugs found + fixed

Scoped to hunt only for NEW defects (the eight prior resolutions were excluded). Both correctness findings reproduced live before fixing; fixed in 556d243d:

# Sev Finding Fix
F1 🔴 Correctness --by-group emitted two incompatible schemas — with no in-scope returns-group: tags it fell back to the single-summary object (no groups/total), breaking JSON consumers Always emits the grouped schema ("groups": [] + total); the notice stays on stderr
F2 🔴 Correctness A raw control byte (e.g. U+001B) in a returns-group: value produced invalid JSON — report's json_escape was aliased to escape_string, the beancount-source escaper (only " \ \n \t \r) Added a proper escape_json to core (RFC 8259: \b \f \uXXXX); rewired report's json_escape. Fixes all report JSON surfaces, not just groups
F6 🟡 Hardening Text-label sanitizer missed U+2028/U+2029 (line/paragraph separators) Neutralized alongside control chars in truncate

Deliberately deferred (tool-wide, not returns-specific — worth a separate decision):

  • F3 CSV formula-injection (=/+/-/@ prefix): escape_csv is the shared canonical (BQL CSV too); guarding only this column would be inconsistent, and a global policy change shouldn't ride in on this PR.
  • F4/F5 fixed-width table alignment — a monetary value wider than its column, and Unicode display-width (CJK/combining) counted as scalars. General table-rendering limitations shared across the tool; truncating a money value would be worse than misaligning.
  • F7 the self-containment scan is a second O(groups×txns) pass → folded into the deferred perf work (returns: single-pass realization for twr + shared per-group extraction (currently ~O(groups × flows × directives)) #1832).

Tests added: escape_json unit test, --by-group-with-no-tags grouped-schema test, control-char-label valid-JSON test. Gauntlet green (429 core + 17 returns tests, clippy/doc/fmt).

Four review passes now; the correctness core has been stable since pass 2, and this pass's findings were output-format/schema hygiene rather than model defects.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

crates/rustledger/src/cmd/report_cmd/returns.rs:362

  • Warnings are built from ledger metadata (e.g. returns-group values) and are written to stderr unescaped. A malicious or accidental control character (newline/ESC) in a group label can inject extra lines or terminal control sequences into the warning stream. Consider escaping control characters at the point of printing (e.g. escape_default()), so warnings remain single-line and terminal-safe.
    // Grouping is opt-in; warnings (bad tags, overlaps, non-self-contained
    // groups) go to stderr so they never pollute the report on stdout.
    let group_scopes = build_groups(directives, &whole_scope, &mut |w| eprintln!("warning: {w}"));

…ment

Fifth-review polish (both are follow-ups to the fourth-review fixes):

- The no-tags --by-group text output printed two horizontal rules back to back
  (the group-rows loop emits nothing, so the header rule and the pre-TOTAL rule
  were adjacent). Skip the pre-TOTAL separator when there are no group rows.
- Refresh the comment above the json_escape alias: it still described
  escape_string and claimed behavior-identity, but the alias now points at
  escape_json (which escapes additional control bytes). Corrected, with a note
  not to revert it (that would reintroduce invalid-JSON output).

Test asserts the no-groups text output has no adjacent rules.
Copilot AI review requested due to automatic review settings July 21, 2026 15:25
@robcohen

Copy link
Copy Markdown
Member Author

Fifth deep review — converged (2 minor follow-ups)

As expected, yield has flattened. Both findings were consequences of the fourth-pass fixes, both fixed in 20cb1fc0:

  • Text layout: the no-tags --by-group text output printed two horizontal rules back-to-back (no group rows between the header rule and the pre-TOTAL rule). Now skips the separator when there are no group rows. Test added.
  • Stale comment: the comment above the json_escape alias still described escape_string/behavior-identity after the switch to escape_json. Corrected, with a do-not-revert note.

No correctness or model defects this pass. Gauntlet green (17 returns + core tests, clippy/doc/fmt). Five passes in, the feature has converged.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.

…on display

Addresses Copilot review threads on #1841:

- Horizon consistency (the substantive one): build_groups iterated every `open`
  and first_shared_inscope_account scanned every transaction regardless of --end,
  while flow extraction/valuation are horizon-bounded. A historical report
  (--end in the past) therefore showed spurious all-zero rows for groups whose
  accounts open later, and could raise false self-containment warnings from
  post-horizon transfers. Both now skip directives dated after --end.
- Label sanitization: a `returns-group:` value is arbitrary user text; factor the
  control-char/U+2028/U+2029 neutralization out of truncate() into
  sanitize_display() and also apply it to the CSV label and the stderr warnings
  (so a label can't inject terminal escapes or extra lines on a human-facing
  surface). JSON keeps escape_json (lossless).

Test: a group whose account opens after --end does not appear in an earlier report.
Copilot AI review requested due to automatic review settings July 21, 2026 15:35
@robcohen

Copy link
Copy Markdown
Member Author

Copilot review threads — resolutions

Addressing the outstanding inline threads (fixed in 1bd649f9):

Horizon bounding (the substantive catch — thank you). build_groups iterated every open and first_shared_inscope_account scanned every transaction regardless of --end, while flow extraction/valuation are horizon-bounded. A historical report (--end in the past) therefore showed spurious all-zero rows for groups whose accounts open later, and could raise false self-containment warnings from post-horizon transfers. Fixed: both now take end_date and skip directives dated after it. Reproduced (a Crypto group opened in 2022 no longer appears in a 2020 report) and pinned with by_group_respects_the_end_horizon.

stderr warning injection (group name). Warnings interpolated the raw, user-controlled group name. Fixed: factored the control-char / U+2028-U+2029 neutralization out of truncate() into sanitize_display() and applied it to the stderr warnings.

CSV control characters in the label. Fixed: the CSV label now passes through sanitize_display() before csv_escape (JSON keeps escape_json, which is lossless). Note: CSV formula-injection (=/+/-/@) remains a tool-wide policy for the shared escape_csv and is intentionally out of scope for this PR.

n/a% in grouped text (outdated). Already fixed earlier in this PR via fmt_rate_pct (undefined rate renders n/a, not n/a%).

Centralizing returns-group metadata parsing in core. There is no existing canonical metadata-string getter to reuse (checked); the parse here is a single MetaValue::String match with two typed warnings. Extracting a core abstraction for one call site isn't warranted — will revisit if a second consumer needs the same tag.

…eturned

The build_groups warn closure was reformatted into block form when the
end_date argument lengthened the call; its eprintln! body needs a trailing
semicolon under the workspace -D warnings clippy gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gJH7uBBF9Xw9et9QFRcsU

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Copilot AI review requested due to automatic review settings July 21, 2026 15:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

crates/rustledger/src/cmd/report_cmd/returns.rs:656

  • The truncate doc comment says “The JSON and CSV paths escape the raw label”, but the CSV path actually calls sanitize_display(&r.label) before csv_escape(...), so CSV output is also sanitized (control chars replaced with spaces) rather than losslessly escaped. This is misleading for anyone relying on CSV to preserve the label verbatim.
/// Control characters (e.g. a newline in a quoted `returns-group:` value) are
/// replaced with a space first, so a label can neither split the fixed-width row
/// across lines nor inject a spoofed second line into the text report. The JSON
/// and CSV paths escape the raw label; only the text table needs this.

Copilot noted --by-group CSV was untested (only JSON/text were). Assert the
CSV header, a group data row, and the TOTAL row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gJH7uBBF9Xw9et9QFRcsU
Copilot AI review requested due to automatic review settings July 21, 2026 16:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

…at handling

The comments claimed JSON escapes labels as \uXXXX (true only for C0 bytes;
U+2028/U+2029 stay raw) and that the CSV path escapes the raw label (it
sanitizes via sanitize_display). Corrected both to match the actual behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gJH7uBBF9Xw9et9QFRcsU
Copilot AI review requested due to automatic review settings July 21, 2026 16:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

…n rustdoc

Empty result now emits the grouped shape (empty group list + total), not the
plain single summary; also document that grouping is end_date-bounded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gJH7uBBF9Xw9et9QFRcsU
Copilot AI review requested due to automatic review settings July 21, 2026 16:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

@robcohen
robcohen merged commit 5d5716d into main Jul 21, 2026
55 checks passed
@robcohen
robcohen deleted the feat/returns-per-group branch July 21, 2026 17:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants