config, mcp, gui: specs from sources, spec-blind suites, and the bugs they found
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
ledger-config: parse gets a sourced spec. A fiscal year starts on the first of a month (IRS Pub. 538); tax_classification is read by whole words (Treas. Reg. 301.7701-3, Schedule C instructions; "business corp" no longer reads as an S corp, "corporation or S corporation" is refused); duplicate or blank members and shared capital accounts are refused; a negative or non-grammatical receipt threshold is refused; codes trimmed. tests/spec_config.rs (65 spec-blind tests, 14 properties), fuzz target fuzz_config_parse (weekly). Mutation: 55 of 55 viable caught (from 25/38). The receipt policy moves to ledger-config, with one loader (effective_receipt_policy) and one decimal grammar, so the server and the viewer enforce and show the same rule (the viewer had its own: only expense-like debits, a silent $25 default, and no config.json). ledger-mcp: a module spec (MCP 2025-06-18 tools, JSON-RPC 2.0, the operating contract). Amounts in are exactly -?digits[.digits]; a number that is one account's code and another's id (vendors, classes alike) is refused, not guessed; the threshold is validated at startup. Fixed: add_line with a bad class journaled the line before refusing; profit_and_loss_by_class ignored `class`; aging buckets said "0". A wire in front of rmcp answers malformed JSON with -32700 and invalid requests with -32600 and their id (rmcp dropped the first, omitted the id). tests/spec_mcp.rs (26 spec-blind tests: every tool's result against its own outputSchema, a random-call state machine checking the journal is untouched by every refusal, amount grammar, previews, the receipt policy). ledger-gui: money, accounting, and dollars never round (they rounded to cents, banker's style); export file names avoid Windows device names, trailing dots and spaces, empty names, and 255-byte overflow; specs on attention, history, fuzzy search, and axis ticks. ledger-core: the lifetime profit_and_loss is renamed unclosed_earnings (docs/testing.md #3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
5b8910a1cd
commit
3f20df9700
42 files changed
+4636
-363
No files matched your search
@@ -13,11 +13,12 @@ use ledger_config as company;
|
||||
mod policy;
|
||||
mod server;
|
||||
mod types;
|
||||
mod wire;
|
||||
|
||||
use ledger_core::Currency;
|
||||
use ledger_store::Store;
|
||||
use policy::ReceiptPolicy;
|
||||
use rmcp::{ServiceExt, transport::stdio};
|
||||
use rmcp::ServiceExt;
|
||||
use serde::Deserialize;
|
||||
use server::BooksServer;
|
||||
use std::path::PathBuf;
|
||||
@@ -28,10 +29,7 @@ struct Config {
|
||||
/// ISO 4217 base currency for a new store. Default "USD".
|
||||
#[serde(default)]
|
||||
base_currency: Option<String>,
|
||||
/// Receipt policy. Overrides `wanderbooks.toml`'s receipt_threshold;
|
||||
/// with neither, receipts are required above $25.00.
|
||||
#[serde(default)]
|
||||
receipt_policy: Option<ReceiptPolicy>,
|
||||
// `receipt_policy` is read by ledger_config::effective_receipt_policy.
|
||||
}
|
||||
|
||||
fn default_data_dir() -> PathBuf {
|
||||
@@ -105,14 +103,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
},
|
||||
};
|
||||
eprintln!("ledger-mcp: company {:?}", settings.company.name);
|
||||
let policy = match (config.receipt_policy, settings.receipt_threshold) {
|
||||
(Some(policy), _) => policy,
|
||||
(None, Some(threshold)) => ReceiptPolicy {
|
||||
require_receipts_above: threshold,
|
||||
..ReceiptPolicy::default()
|
||||
},
|
||||
(None, None) => ReceiptPolicy::default(),
|
||||
};
|
||||
let policy = company::effective_receipt_policy(&config_path, &settings)?;
|
||||
let roles = server::CashRoles {
|
||||
cash: settings.cash_accounts,
|
||||
financing: settings.financing_accounts,
|
||||
@@ -152,8 +143,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.with_company(company)
|
||||
.with_cash_roles(roles);
|
||||
rt.block_on(async {
|
||||
let running = server.serve(stdio()).await?;
|
||||
// The wire answers what JSON-RPC requires that rmcp drops (wire.rs).
|
||||
let (input, output, writer) = wire::spawn(tokio::io::stdin(), tokio::io::stdout());
|
||||
let running = server.serve((input, output)).await?;
|
||||
let _ = running.waiting().await;
|
||||
let _ = writer.await;
|
||||
Ok::<(), Box<dyn std::error::Error>>(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,192 +1,4 @@
|
||||
//! The receipt policy: server-side enforcement, not prose.
|
||||
//!
|
||||
//! Posting an entry whose debits are strictly greater than the configured
|
||||
//! threshold requires at least one attached receipt, unless every line is in
|
||||
//! an exempt account. Selected accounts can require receipts regardless of
|
||||
//! amount. Reversals are exempt: the original entry carries the evidence.
|
||||
//! The receipt policy lives in `ledger-config` (shared with the viewer, so
|
||||
//! both read one rule); re-exported here.
|
||||
|
||||
use ledger_core::{JournalEntry, Ledger, Side};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Server-side receipt policy, loaded from config.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ReceiptPolicy {
|
||||
/// Receipts required when an entry's debits are strictly greater than
|
||||
/// this amount. Decimal string, e.g. `"25.00"`.
|
||||
#[serde(default = "default_threshold")]
|
||||
pub require_receipts_above: String,
|
||||
/// Account codes that always require a receipt, regardless of amount.
|
||||
#[serde(default)]
|
||||
pub receipt_required_accounts: Vec<String>,
|
||||
/// Account codes exempt from the policy. The exemption applies only when
|
||||
/// *every* line of the entry is in an exempt account.
|
||||
#[serde(default)]
|
||||
pub receipt_exempt_accounts: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_threshold() -> String {
|
||||
"25.00".to_string()
|
||||
}
|
||||
|
||||
impl Default for ReceiptPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
require_receipts_above: default_threshold(),
|
||||
receipt_required_accounts: Vec::new(),
|
||||
receipt_exempt_accounts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ReceiptPolicy {
|
||||
fn threshold(&self) -> Decimal {
|
||||
self.require_receipts_above
|
||||
.parse()
|
||||
.unwrap_or(Decimal::from(25))
|
||||
}
|
||||
|
||||
fn account_codes(&self, ledger: &Ledger, entry: &JournalEntry) -> Vec<String> {
|
||||
entry
|
||||
.lines()
|
||||
.iter()
|
||||
.map(|l| {
|
||||
ledger
|
||||
.chart()
|
||||
.get(l.account)
|
||||
.map(|a| a.code.clone())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Total debits of the entry (one side of the balanced entry).
|
||||
pub fn entry_debits(entry: &JournalEntry) -> Decimal {
|
||||
entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == Side::Debit)
|
||||
.map(|l| l.amount.amount)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// True when posting this entry requires at least one receipt.
|
||||
pub fn receipt_required(&self, ledger: &Ledger, entry: &JournalEntry) -> bool {
|
||||
let codes = self.account_codes(ledger, entry);
|
||||
let refs: Vec<&str> = codes.iter().map(|s| s.as_str()).collect();
|
||||
self.receipt_required_for(&refs, Self::entry_debits(entry))
|
||||
}
|
||||
|
||||
/// True when an entry touching these account codes with this debit total
|
||||
/// requires at least one receipt. The code-level check behind
|
||||
/// [`Self::receipt_required`], for postings (like disposals) computed
|
||||
/// without a draft entry in hand.
|
||||
pub fn receipt_required_for(&self, codes: &[&str], debits: Decimal) -> bool {
|
||||
// Per-account override: any line in a required account → required.
|
||||
if codes
|
||||
.iter()
|
||||
.any(|c| self.receipt_required_accounts.iter().any(|r| r == *c))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Strictly greater than the threshold (Stephen: ">$25").
|
||||
if debits <= self.threshold() {
|
||||
return false;
|
||||
}
|
||||
// Exempt only when every line is in an exempt account.
|
||||
!codes
|
||||
.iter()
|
||||
.all(|c| self.receipt_exempt_accounts.iter().any(|e| e == *c))
|
||||
}
|
||||
|
||||
/// Human-readable policy summary for previews and errors.
|
||||
pub fn describe(&self) -> String {
|
||||
if self.threshold().is_zero() {
|
||||
"receipts required on every entry".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"receipts required when entry debits are strictly greater than {}",
|
||||
self.threshold()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The receipt rule, phrased for the agent's operating instructions.
|
||||
pub fn rule(&self) -> String {
|
||||
if self.threshold().is_zero() {
|
||||
"every entry needs at least one receipt".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"any entry moving more than ${} in debits needs at least one receipt",
|
||||
self.threshold().round_dp(2)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a description for split detection.
|
||||
fn normalize_description(desc: &str) -> String {
|
||||
desc.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
/// Debit account ids of an entry, sorted.
|
||||
fn debit_accounts(entry: &JournalEntry) -> Vec<u64> {
|
||||
let mut ids: Vec<u64> = entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == Side::Debit)
|
||||
.map(|l| l.account)
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
ids
|
||||
}
|
||||
|
||||
/// Detect a potential threshold split: posted entries on the same date
|
||||
/// with the same normalized description and debit accounts, whose
|
||||
/// combined debits with this entry exceed the threshold while this
|
||||
/// entry alone does not. Returns the matching posted entry ids.
|
||||
///
|
||||
/// This is the anti-gaming rule: splitting a $100 purchase into four
|
||||
/// $25 entries does not dodge the receipt policy. Same-day,
|
||||
/// same-description, same-accounts is the signature; recurring spending
|
||||
/// spread across days is not flagged.
|
||||
pub fn split_group(&self, ledger: &Ledger, entry_id: u64, entry: &JournalEntry) -> Vec<u64> {
|
||||
let debits = Self::entry_debits(entry);
|
||||
if debits > self.threshold() {
|
||||
return Vec::new(); // Normal policy already applies.
|
||||
}
|
||||
let desc = Self::normalize_description(entry.description());
|
||||
let accounts = Self::debit_accounts(entry);
|
||||
let date = entry.date();
|
||||
let mut group = Vec::new();
|
||||
let mut total = debits;
|
||||
for other in ledger.posted_entries() {
|
||||
if other.id() == entry_id {
|
||||
continue;
|
||||
}
|
||||
if other.date() != date {
|
||||
continue;
|
||||
}
|
||||
if Self::normalize_description(other.description()) != desc {
|
||||
continue;
|
||||
}
|
||||
if Self::debit_accounts(other) != accounts {
|
||||
continue;
|
||||
}
|
||||
// Reversals are exempt from the policy; don't count them.
|
||||
if other.reversal_of().is_some() {
|
||||
continue;
|
||||
}
|
||||
group.push(other.id());
|
||||
total += Self::entry_debits(other);
|
||||
}
|
||||
if !group.is_empty() && total > self.threshold() {
|
||||
group
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use ledger_config::ReceiptPolicy;
|
||||
+102
-42
@@ -1,5 +1,34 @@
|
||||
//! The MCP server: narrow typed tools over the store, with the receipt
|
||||
//! policy enforced server-side.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: let an AI agent keep the books through tools that cannot leave
|
||||
//! them wrong, and say exactly what happened.
|
||||
//! SOURCE: docs/spec/sources/mcp-tools.md (MCP 2025-06-18 tools: structured
|
||||
//! results conform to the declared output schema; tool errors are
|
||||
//! results with isError; "validate all tool inputs");
|
||||
//! docs/spec/sources/jsonrpc-2.0.md (one response per request, id
|
||||
//! echoed, result xor error); DECISION (Stephen, the operating
|
||||
//! contract in INSTRUCTIONS and docs/mcp-interface.md).
|
||||
//! INVARIANTS:
|
||||
//! 1. Every tool's structured result MUST conform to its declared output
|
||||
//! schema, and the same JSON MUST be in a text block.
|
||||
//! 2. A refused call (isError) MUST leave the books unchanged: the
|
||||
//! journal byte for byte.
|
||||
//! 3. Amounts in MUST be decimal strings, exactly `-?digits[.digits]`
|
||||
//! (no exponent, sign "+", separators, or spaces); amounts out are
|
||||
//! decimal strings, never JSON numbers, with at least two places and
|
||||
//! never rounded.
|
||||
//! 4. Posting and reversing MUST preview without `confirmed: true`, and a
|
||||
//! preview MUST change nothing.
|
||||
//! 5. An entry the receipt policy covers MUST NOT post, or keep posted,
|
||||
//! without a receipt (crate::policy).
|
||||
//! 6. A reference (account, vendor, class) given as a number that names
|
||||
//! one thing by id and another by code or name is refused, never
|
||||
//! guessed.
|
||||
//! 7. The server MUST answer every request with exactly one response
|
||||
//! whose id is the request's, and keep serving after any tool error.
|
||||
//! ```
|
||||
|
||||
use crate::policy::ReceiptPolicy;
|
||||
use crate::types::*;
|
||||
@@ -330,9 +359,10 @@ fn parse_date(s: &str) -> Result<NaiveDate, String> {
|
||||
.map_err(|_| format!("bad date {s:?}: expected YYYY-MM-DD"))
|
||||
}
|
||||
|
||||
/// A decimal string, exactly `-?digits[.digits]` (INVARIANT 3).
|
||||
fn parse_amount(s: &str) -> Result<Decimal, String> {
|
||||
s.parse::<Decimal>()
|
||||
.map_err(|_| format!("bad amount {s:?}: expected a decimal string like \"123.45\""))
|
||||
ledger_config::decimal(s)
|
||||
.ok_or_else(|| format!("bad amount {s:?}: expected a decimal string like \"123.45\""))
|
||||
}
|
||||
|
||||
fn parse_side(s: &str) -> Result<Side, String> {
|
||||
@@ -378,28 +408,35 @@ fn parse_account_type(s: &str) -> Result<AccountType, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an account reference: a numeric id, or an account code.
|
||||
/// Resolve an account reference: an account code, or a numeric id. A
|
||||
/// number that is one account's code and another's id is refused
|
||||
/// (INVARIANT 6).
|
||||
fn resolve_account(store: &Store, account: &str) -> Result<AccountId, String> {
|
||||
if let Ok(id) = account.parse::<u64>()
|
||||
&& store.ledger().chart().get(id).is_some()
|
||||
{
|
||||
return Ok(id);
|
||||
let chart = store.ledger().chart();
|
||||
let by_code = chart.find_by_code(account).map(|a| a.id);
|
||||
let by_id = account
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|id| chart.get(*id).is_some());
|
||||
match (by_code, by_id) {
|
||||
(Some(c), Some(i)) if c != i => Err(format!(
|
||||
"account {account:?} is ambiguous: it is the code of account {c} and the id of account {i}; pass the code of the one you mean"
|
||||
)),
|
||||
(Some(c), _) => Ok(c),
|
||||
(None, Some(i)) => Ok(i),
|
||||
(None, None) => Err(format!(
|
||||
"unknown account {account:?}: use an id or code from books.chart"
|
||||
)),
|
||||
}
|
||||
store
|
||||
.ledger()
|
||||
.chart()
|
||||
.find_by_code(account)
|
||||
.map(|a| a.id)
|
||||
.ok_or_else(|| format!("unknown account {account:?}: use an id or code from books.chart"))
|
||||
}
|
||||
|
||||
/// Resolve a class by id or name (case-insensitive).
|
||||
/// Resolve a vendor by id or name (case-insensitive); a number that is
|
||||
/// one vendor's id and another's name is refused (INVARIANT 6).
|
||||
fn resolve_vendor(store: &Store, vendor: &str) -> Result<u64, String> {
|
||||
if let Ok(id) = vendor.parse::<u64>()
|
||||
&& store.ledger().get_vendor(id).is_some()
|
||||
{
|
||||
return Ok(id);
|
||||
}
|
||||
let by_id = vendor
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|id| store.ledger().get_vendor(*id).is_some());
|
||||
let matches: Vec<u64> = store
|
||||
.ledger()
|
||||
.list_vendors()
|
||||
@@ -407,6 +444,15 @@ fn resolve_vendor(store: &Store, vendor: &str) -> Result<u64, String> {
|
||||
.filter(|v| v.name.eq_ignore_ascii_case(vendor.trim()))
|
||||
.map(|v| v.id)
|
||||
.collect();
|
||||
if let Some(id) = by_id {
|
||||
return match matches.as_slice() {
|
||||
[] => Ok(id),
|
||||
[m] if *m == id => Ok(id),
|
||||
_ => Err(format!(
|
||||
"vendor {vendor:?} is ambiguous: it is vendor {id}'s id and another vendor's name; pass the id"
|
||||
)),
|
||||
};
|
||||
}
|
||||
match matches.as_slice() {
|
||||
[id] => Ok(*id),
|
||||
[] => Err(format!(
|
||||
@@ -420,19 +466,23 @@ fn form_1099_label(form: Option<ledger_core::Form1099>) -> Option<String> {
|
||||
form.map(|f| f.label().to_string())
|
||||
}
|
||||
|
||||
/// Resolve a class by id or name; a number that is one class's id and
|
||||
/// another's name is refused (INVARIANT 6).
|
||||
fn resolve_class(store: &Store, class: &str) -> Result<u32, String> {
|
||||
if let Ok(id) = class.parse::<u32>()
|
||||
&& store.ledger().get_class(id).is_some()
|
||||
{
|
||||
return Ok(id);
|
||||
let by_id = class
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.filter(|id| store.ledger().get_class(*id).is_some());
|
||||
let by_name = store.ledger().find_class_by_name(class).map(|c| c.id);
|
||||
match (by_id, by_name) {
|
||||
(Some(i), Some(n)) if i != n => Err(format!(
|
||||
"class {class:?} is ambiguous: it is class {i}'s id and class {n}'s name; pass the id"
|
||||
)),
|
||||
(Some(i), _) | (None, Some(i)) => Ok(i),
|
||||
(None, None) => Err(format!(
|
||||
"unknown class {class:?}: use an id or name from books.list_classes"
|
||||
)),
|
||||
}
|
||||
store
|
||||
.ledger()
|
||||
.find_class_by_name(class)
|
||||
.map(|c| c.id)
|
||||
.ok_or_else(|| {
|
||||
format!("unknown class {class:?}: use an id or name from books.list_classes")
|
||||
})
|
||||
}
|
||||
|
||||
/// A money amount as a decimal string with at least two places: "0.00",
|
||||
@@ -923,6 +973,11 @@ impl BooksServer {
|
||||
let mut store = self.lock()?;
|
||||
let account = resolve_account(&store, &p.account)?;
|
||||
let side = parse_side(&p.side)?;
|
||||
// Resolve the class before writing anything (INVARIANT 2).
|
||||
let class_id = match &p.class {
|
||||
Some(c) => Some(resolve_class(&store, c)?),
|
||||
None => None,
|
||||
};
|
||||
// Foreign-currency line?
|
||||
if p.foreign_currency.is_some() || p.foreign_amount.is_some() || p.fx_rate.is_some() {
|
||||
let fc = p
|
||||
@@ -949,8 +1004,7 @@ impl BooksServer {
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
// Class tag, if requested. The line just added is the last one.
|
||||
if let Some(class_ref) = p.class {
|
||||
let class_id = resolve_class(&store, &class_ref)?;
|
||||
if let Some(class_id) = class_id {
|
||||
let line_index = store
|
||||
.ledger()
|
||||
.get_entry(p.entry_id)
|
||||
@@ -3362,11 +3416,11 @@ impl BooksServer {
|
||||
let total = r.total().to_string();
|
||||
AgingRowOut {
|
||||
name: r.name,
|
||||
current: r.current.to_string(),
|
||||
days_30: r.days_30.to_string(),
|
||||
days_60: r.days_60.to_string(),
|
||||
days_90: r.days_90.to_string(),
|
||||
days_90_plus: r.days_90_plus.to_string(),
|
||||
current: money(r.current),
|
||||
days_30: money(r.days_30),
|
||||
days_60: money(r.days_60),
|
||||
days_90: money(r.days_90),
|
||||
days_90_plus: money(r.days_90_plus),
|
||||
total,
|
||||
}
|
||||
})
|
||||
@@ -3389,11 +3443,11 @@ impl BooksServer {
|
||||
let total = r.total().to_string();
|
||||
AgingRowOut {
|
||||
name: r.name,
|
||||
current: r.current.to_string(),
|
||||
days_30: r.days_30.to_string(),
|
||||
days_60: r.days_60.to_string(),
|
||||
days_90: r.days_90.to_string(),
|
||||
days_90_plus: r.days_90_plus.to_string(),
|
||||
current: money(r.current),
|
||||
days_30: money(r.days_30),
|
||||
days_60: money(r.days_60),
|
||||
days_90: money(r.days_90),
|
||||
days_90_plus: money(r.days_90_plus),
|
||||
total,
|
||||
}
|
||||
})
|
||||
@@ -3584,6 +3638,11 @@ impl BooksServer {
|
||||
if end < start {
|
||||
return Err("end is before start".to_string());
|
||||
}
|
||||
// One class, if asked (validated like every reference).
|
||||
let only = match &p.class {
|
||||
Some(c) => Some(resolve_class(&store, c)?),
|
||||
None => None,
|
||||
};
|
||||
// Per-class P&L over the date range: accumulate line by line.
|
||||
use std::collections::HashMap;
|
||||
#[derive(Default)]
|
||||
@@ -3621,6 +3680,7 @@ impl BooksServer {
|
||||
// Sort: named classes by name, unclassified last.
|
||||
let mut rows: Vec<(Option<String>, Acc)> = by_class
|
||||
.into_iter()
|
||||
.filter(|(cid, _)| only.is_none() || *cid == only)
|
||||
.map(|(cid, acc)| {
|
||||
let name = cid
|
||||
.and_then(|id| ledger.get_class(id))
|
||||
|
||||
@@ -566,9 +566,9 @@ pub struct TrialBalanceLineOut {
|
||||
pub code: String,
|
||||
/// Account name.
|
||||
pub name: String,
|
||||
/// Debit balance as a decimal string, or "0".
|
||||
/// Debit balance, decimal string ("0.00" when none).
|
||||
pub debit: String,
|
||||
/// Credit balance as a decimal string, or "0".
|
||||
/// Credit balance, decimal string ("0.00" when none).
|
||||
pub credit: String,
|
||||
}
|
||||
|
||||
@@ -1570,7 +1570,7 @@ pub struct DisposeAssetOut {
|
||||
/// Gain (positive) or loss (negative), decimal string.
|
||||
pub gain_loss: String,
|
||||
/// Depreciation auto-posted for the final partial period, decimal string
|
||||
/// ("0" when the period was already posted).
|
||||
/// ("0.00" when the period was already posted).
|
||||
pub final_depreciation: String,
|
||||
/// The disposal entry's lines with resulting balances.
|
||||
pub lines: Vec<PreviewLine>,
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! The stdio wire, in front of rmcp: what JSON-RPC 2.0 requires that the
|
||||
//! transport doesn't do.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: every line the client sends gets the response JSON-RPC 2.0 says
|
||||
//! it gets, even one rmcp would drop.
|
||||
//! SOURCE: docs/spec/sources/jsonrpc-2.0.md (5: one response per request,
|
||||
//! id echoed, or null when it can't be detected; 5.1: -32700 Parse
|
||||
//! error, -32600 Invalid Request); MCP 2025-06-18 has no batches.
|
||||
//! INVARIANTS:
|
||||
//! 1. A line that isn't JSON gets {"code": -32700} with id null.
|
||||
//! 2. JSON that isn't a request, notification, or response object (an
|
||||
//! array, a scalar, an object without "jsonrpc": "2.0" and a string
|
||||
//! "method" or a result/error) gets {"code": -32600}, with the
|
||||
//! line's id when it has a valid one (string, number, null), else
|
||||
//! null.
|
||||
//! 3. Everything else goes to rmcp unchanged; a blank line is ignored.
|
||||
//! 4. Output lines are written whole: replies made here never split a
|
||||
//! line rmcp writes.
|
||||
//! ```
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// What to do with one line from the client.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Line {
|
||||
/// Hand it to rmcp.
|
||||
Forward,
|
||||
/// Ignore it (blank).
|
||||
Skip,
|
||||
/// Answer it here with this JSON-RPC response.
|
||||
Reply(Value),
|
||||
}
|
||||
|
||||
/// Classify one line (INVARIANTS 1-3).
|
||||
pub fn classify(line: &str) -> Line {
|
||||
if line.trim().is_empty() {
|
||||
return Line::Skip;
|
||||
}
|
||||
let v: Value = match serde_json::from_str(line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Line::Reply(error(Value::Null, -32700, "Parse error")),
|
||||
};
|
||||
let Some(obj) = v.as_object() else {
|
||||
return Line::Reply(error(Value::Null, -32600, "Invalid Request"));
|
||||
};
|
||||
let id = match obj.get("id") {
|
||||
Some(id @ (Value::String(_) | Value::Number(_) | Value::Null)) => id.clone(),
|
||||
_ => Value::Null,
|
||||
};
|
||||
let version_ok = obj.get("jsonrpc").and_then(Value::as_str) == Some("2.0");
|
||||
let id_ok = match obj.get("id") {
|
||||
None => true,
|
||||
Some(v) => matches!(v, Value::String(_) | Value::Number(_) | Value::Null),
|
||||
};
|
||||
let request = obj.get("method").is_some_and(Value::is_string);
|
||||
let response =
|
||||
obj.contains_key("id") && (obj.contains_key("result") ^ obj.contains_key("error"));
|
||||
if version_ok && id_ok && (request || response) {
|
||||
Line::Forward
|
||||
} else {
|
||||
Line::Reply(error(id, -32600, "Invalid Request"))
|
||||
}
|
||||
}
|
||||
|
||||
fn error(id: Value, code: i64, message: &str) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
|
||||
}
|
||||
|
||||
/// Run the wire between the real `input`/`output` and rmcp's ends:
|
||||
/// returns (what rmcp reads, what rmcp writes) and the task that must be
|
||||
/// awaited after rmcp finishes, so the last lines are flushed.
|
||||
pub fn spawn<R, W>(
|
||||
input: R,
|
||||
mut output: W,
|
||||
) -> (
|
||||
tokio::io::DuplexStream,
|
||||
tokio::io::DuplexStream,
|
||||
tokio::task::JoinHandle<()>,
|
||||
)
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
const BUF: usize = 1 << 20;
|
||||
let (rmcp_in, mut feed) = tokio::io::duplex(BUF);
|
||||
let (rmcp_out, from_rmcp) = tokio::io::duplex(BUF);
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
|
||||
let replies = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(input).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
match classify(&line) {
|
||||
Line::Skip => {}
|
||||
Line::Forward => {
|
||||
if feed
|
||||
.write_all(format!("{line}\n").as_bytes())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Line::Reply(v) => {
|
||||
let _ = replies.send(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dropping `feed` ends rmcp's input.
|
||||
});
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(from_rmcp).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let writer = tokio::spawn(async move {
|
||||
while let Some(line) = rx.recv().await {
|
||||
if output
|
||||
.write_all(format!("{line}\n").as_bytes())
|
||||
.await
|
||||
.is_err()
|
||||
|| output.flush().await.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
(rmcp_in, rmcp_out, writer)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn code(l: Line) -> (i64, Value) {
|
||||
match l {
|
||||
Line::Reply(v) => (v["error"]["code"].as_i64().unwrap(), v["id"].clone()),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_by_the_jsonrpc_spec() {
|
||||
assert_eq!(
|
||||
code(classify(r#"{"jsonrpc":"2.0","id":99,"method":"#)),
|
||||
(-32700, Value::Null)
|
||||
);
|
||||
assert_eq!(code(classify("not json")), (-32700, Value::Null));
|
||||
assert_eq!(code(classify("[]")), (-32600, Value::Null));
|
||||
assert_eq!(code(classify("[1]")), (-32600, Value::Null));
|
||||
assert_eq!(code(classify("5")), (-32600, Value::Null));
|
||||
assert_eq!(
|
||||
code(classify(r#"{"jsonrpc":"2.0","id":98}"#)),
|
||||
(-32600, json!(98))
|
||||
);
|
||||
assert_eq!(
|
||||
code(classify(r#"{"jsonrpc":"1.0","id":"a","method":"x"}"#)),
|
||||
(-32600, json!("a"))
|
||||
);
|
||||
assert_eq!(
|
||||
code(classify(r#"{"jsonrpc":"2.0","id":[1],"method":"x"}"#)),
|
||||
(-32600, Value::Null)
|
||||
);
|
||||
assert_eq!(
|
||||
code(classify(r#"{"jsonrpc":"2.0","method":5}"#)),
|
||||
(-32600, Value::Null)
|
||||
);
|
||||
assert_eq!(
|
||||
classify(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#),
|
||||
Line::Forward
|
||||
);
|
||||
assert_eq!(
|
||||
classify(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#),
|
||||
Line::Forward
|
||||
);
|
||||
assert_eq!(
|
||||
classify(r#"{"jsonrpc":"2.0","id":7,"result":{}}"#),
|
||||
Line::Forward
|
||||
);
|
||||
assert_eq!(
|
||||
classify(r#"{"jsonrpc":"2.0","id":7,"error":{"code":1,"message":"x"}}"#),
|
||||
Line::Forward
|
||||
);
|
||||
assert_eq!(
|
||||
code(classify(
|
||||
r#"{"jsonrpc":"2.0","id":7,"result":{},"error":{}}"#
|
||||
)),
|
||||
(-32600, json!(7))
|
||||
);
|
||||
assert_eq!(classify(" "), Line::Skip);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user