Files
wanderbooks/crates/ledger-gui/src/attention.rs
T
swaitsandClaude Fable 5.1 2ad192bbcd merge: P6, the evidence pipeline (branch P6, checkpoint ce366408)
The last of the paused branches, from its "not for merge" checkpoint:
extraction in ledger-import (text, pages, page images; capped, caught),
journaled as attachment.text_extracted; dedup by hash with
books.find_attachment and books.consolidate_duplicates; annotations and
books.set_entry_settles; the company record categories; the two fuzz
targets; feature attachment_text. Nineteen files conflicted with the
four P4b merges, resolved as plan 8.0.2 says: P6 retakes schema 18 (it
had taken 16, which S4.3 holds; S5 holds 17); the tool table is 129 and
the destructive count 31; both sides' appends kept with their seams
restored; one B-27 paragraph naming every feature; in the old-books
fixture P6's hand-written reimbursement is entry 7 (its ids collided
with S4.3's invoice); the replay fuzzer's list gains S7's
invoice.written_off; S4.3's issued-PDF test expects the extraction
record P6 journals right after a filing.

One integration defect between S7 and P6, found by the arap property
test and fixed here: under P6's dedup, a write-off whose evidence bytes
were filed already (the same collection letter for a second write-off of
the same invoice) was refused only after its entry had been journaled,
at the final link to the invoice ("already linked"). The write-off now
resolves the dedup before anything is journaled and keeps a link the
record already has to the invoice instead of refusing it; an explicit
existing_attachment_id already on an entry is still refused (S7's
one-receipt-once rule). P6's gate never ran before the pause; it runs on
main after this merge, and its ledger-store -> ledger-import dependency
is the open review point (plan 8.0.1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-28 23:08:59 -06:00

296 lines
11 KiB
Rust

//! "Needs attention", mapped for the viewer: the items come from
//! `ledger_package::attention` (the same function `books.start` reads), and
//! this module only turns each `Target` into the viewer's [`Route`] and each
//! severity into the viewer's own.
//!
//! ```text
//! GOAL: the viewer never computes an attention item itself; it draws the
//! ones the shared engine found, so the list can't drift from the
//! server's next steps.
//! SOURCE: plan/served.md U0b (D-53: Needs attention lives in
//! ledger-package over PackageBooks); ledger_package::attention
//! (the conditions, the prompts, the INVARIANTS).
//! INVARIANTS:
//! 1. Pure: reads `b`, writes nothing.
//! 2. One item out per item the shared engine found, in the same order,
//! with the same severity, title, detail, and prompt.
//! 3. `target` is the item's `Target` mapped to this viewer's `Route`.
//! ```
use crate::books::{Attention, Books, Severity};
use crate::nav::{ReportKind, Route};
use ledger_store::{
Attachment, DiskCheck, JournalLine, Reconciliation, Statement, StoreError, Subject,
};
/// Everything the books need, as items to show.
pub fn find(b: &Books) -> Vec<Attention> {
let settings = ledger_package::Settings {
company: &b.settings.company,
cash_accounts: &b.settings.cash_accounts,
financing_accounts: &b.settings.financing_accounts,
investing_accounts: &b.settings.investing_accounts,
};
let integrity = ledger_package::Integrity {
disk: b.integrity.disk.clone(),
file_failures: b.integrity.file_failures.clone(),
};
ledger_package::find_with(&View(b), b.today, &settings, &integrity)
.into_iter()
.map(|a| Attention {
severity: severity(a.severity),
title: a.title,
detail: a.detail,
target: route(a.target),
prompt: a.prompt,
})
.collect()
}
fn severity(s: ledger_package::Severity) -> Severity {
match s {
ledger_package::Severity::Problem => Severity::Problem,
ledger_package::Severity::Todo => Severity::Todo,
ledger_package::Severity::Info => Severity::Info,
}
}
fn route(t: ledger_package::Target) -> Option<Route> {
Some(match t {
ledger_package::Target::None => return None,
ledger_package::Target::Entry(e) => Route::Entry(e),
ledger_package::Target::Transactions => Route::Transactions,
ledger_package::Target::Company => Route::Company,
ledger_package::Target::Accounts => Route::Accounts,
ledger_package::Target::Account(a) => Route::Account(a),
ledger_package::Target::Party(p) => Route::Party(p),
ledger_package::Target::Assets => Route::Assets,
ledger_package::Target::Asset(a) => Route::Asset(a),
ledger_package::Target::Documents => Route::Documents,
ledger_package::Target::Invoice(i) => Route::Invoice(i),
ledger_package::Target::Journal => Route::Journal,
ledger_package::Target::Report(r) => Route::Reports(match r {
ledger_package::Report::Vendor1099 => ReportKind::Vendor1099,
ledger_package::Report::ApAging => ReportKind::ApAging,
ledger_package::Report::ArAging => ReportKind::ArAging,
ledger_package::Report::FixedAssetRegister => ReportKind::FixedAssetRegister,
}),
})
}
/// The view model seen through [`PackageBooks`]: the snapshot's own state,
/// already read, handed to the shared engine without reading the books
/// again.
struct View<'a>(&'a Books);
impl ledger_package::PackageBooks for View<'_> {
fn ledger(&self) -> &ledger_core::Ledger {
&self.0.ledger
}
fn journal_seq(&self) -> u64 {
self.0.journal.len() as u64
}
fn journal(&self) -> Result<Vec<JournalLine>, StoreError> {
Ok(self.0.journal.clone())
}
fn all_attachments(&self) -> Result<Vec<Attachment>, StoreError> {
Ok(self.0.attachments.clone())
}
fn extractions(&self) -> Result<Vec<ledger_store::Extraction>, StoreError> {
Ok(self.0.extractions.clone())
}
fn links_of(&self, attachment_id: u64) -> Result<Vec<Subject>, StoreError> {
Ok(self.0.links_of(attachment_id).to_vec())
}
fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
let a = self
.0
.attachment(attachment_id)
.ok_or_else(|| StoreError::Io(std::io::Error::other("no such attachment")))?;
self.0
.attachment_bytes(a)
.map_err(|e| StoreError::Io(std::io::Error::other(e)))
}
fn statements(&self) -> Result<Vec<Statement>, StoreError> {
Ok(self.0.statements.clone())
}
fn reconciliations(&self) -> Result<Vec<Reconciliation>, StoreError> {
Ok(self.0.reconciliations.clone())
}
fn disk_check(&self) -> DiskCheck {
self.0.integrity.disk.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Empty books opened as of `today`.
fn books(tag: &str, today: chrono::NaiveDate) -> Books {
let dir =
std::env::temp_dir().join(format!("ledger-gui-attention-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
// Loaded books need no files: removed once loaded, pass or panic.
let _cleanup = crate::test_support::TempDir(dir.clone());
let data_dir = dir.join("books-data");
ledger_store::Store::create(&data_dir, ledger_core::Currency::USD).unwrap();
crate::data::load_once(&crate::data::Source {
data_dir,
today: Some(today),
})
.unwrap()
}
/// INVARIANT 2's cash flow items (rules.md B-14 rule 12, D-35): a
/// sale of equipment for an IOU is a Todo naming the entry, with the
/// statement's fix; equipment bought on an operating card is Info; a
/// sale for cash asks nothing.
#[test]
fn cash_flow_warnings_are_items() {
use crate::test_support::{Build, d};
use ledger_core::{AccountType, Currency, Money, NewAsset, Side};
let mut b = Build::new("attention-cf");
let mut add =
|code: &str, name: &str, ty| b.s.add_account(code, name, ty, None, false).unwrap();
let equipment = add("1500", "Equipment", AccountType::Asset);
let accum = add("1510", "Accumulated Depreciation", AccountType::Asset);
let iou = add("1210", "Due from buyer", AccountType::Asset);
let card = add("2050", "Card", AccountType::Liability);
let depr = add("6500", "Depreciation", AccountType::Expense);
let import = ledger_config::codec::import(
"[books]\ncash_accounts = [\"1000\"]\n",
None,
b.s.ledger().settings(),
"USD",
)
.unwrap();
b.s.adopt_settings(
&import.changes,
&ledger_core::settings::Source {
decided_by: "the test".into(),
decided_on: d(2026, 1, 1),
citation: None,
note: None,
},
)
.unwrap();
let (cash, capital) = (b.cash, b.capital);
b.post(d(2026, 2, 1), cash, capital, 500_000);
b.s.register_asset(NewAsset {
description: "Laptop".into(),
in_service_date: d(2026, 2, 2),
cost: Money::new(rust_decimal::Decimal::new(100_000, 2), Currency::USD),
salvage_value: Money::new(rust_decimal::Decimal::ZERO, Currency::USD),
useful_life_months: 36,
cost_account: equipment,
accum_depr_account: accum,
expense_account: depr,
})
.unwrap();
let bought = b.post(d(2026, 2, 2), equipment, card, 100_000);
let usd = |c: i64| Money::new(rust_decimal::Decimal::new(c, 2), Currency::USD);
let e =
b.s.create_entry(d(2026, 3, 2), "Sell the laptop on an IOU")
.unwrap();
b.s.add_line(e, iou, Side::Debit, usd(100_000)).unwrap();
b.s.add_line(e, equipment, Side::Credit, usd(100_000))
.unwrap();
b.s.attach_receipt(Some(e), "r.pdf", "application/pdf", b"%PDF r", None)
.unwrap();
b.s.post_entry(e).unwrap();
let (_dir, books) = b.books();
let items = find(&books);
let sold = items
.iter()
.find(|a| a.title == format!("Equipment sold for an IOU: entry #{e}"))
.unwrap_or_else(|| panic!("{items:?}"));
assert_eq!(sold.severity, Severity::Todo);
assert_eq!(sold.target, Some(Route::Entry(e)));
assert!(
sold.detail.contains("books.investing_accounts"),
"{}",
sold.detail
);
assert!(sold.prompt.starts_with("In wanderbooks,"));
let on_card = items
.iter()
.find(|a| {
a.title == format!("Equipment bought on an operating payable: entry #{bought}")
})
.unwrap_or_else(|| panic!("{items:?}"));
assert_eq!(on_card.severity, Severity::Info);
assert_eq!(
items
.iter()
.filter(|a| a.title.starts_with("Equipment"))
.count(),
2
);
}
/// INVARIANTS 2 and 4: a dated rule past its review is one Todo item
/// whose prompt asks the agent to find the figure from the official
/// authority (named, never the repo's copy), confirm it with the human
/// and pass it explicitly, and to tell the human so it can be reported
/// upstream; before the review there is no such item. No prompt points
/// the agent at files, git, GitHub or pull requests.
#[test]
fn a_stale_rule_asks_for_the_authority_and_the_humans_confirmation() {
let rule = ledger_core::rules::THRESHOLD_1099;
let review = rule.review_after();
assert!(
find(&books("fresh", review.pred_opt().unwrap()))
.iter()
.all(|a| !a.title.contains(rule.name)),
"no item before the review date"
);
let items = find(&books("stale", review));
let stale: Vec<_> = items
.iter()
.filter(|a| a.title == format!("Check the {}", rule.name))
.collect();
assert_eq!(stale.len(), 1, "{items:?}");
let a = stale[0];
assert_eq!(a.severity, Severity::Todo);
assert!(
a.detail.contains(&rule.known_through.to_string()),
"{}",
a.detail
);
for want in [
"In wanderbooks,",
rule.name,
&rule.known_through.to_string(),
rule.authority,
"confirm it with me",
"pass it explicitly",
"report it upstream",
] {
assert!(a.prompt.contains(want), "{want:?} missing: {}", a.prompt);
}
for item in &items {
let lower = item.prompt.to_lowercase();
for never in ["pull request", ".md", "docs/", rule.source] {
assert!(!lower.contains(never), "{never:?} in {}", item.prompt);
}
for word in lower.split(|c: char| !c.is_alphanumeric()) {
assert!(
!["git", "github", "repo", "repository", "issue"].contains(&word),
"{word:?} in {}",
item.prompt
);
}
}
}
}