config, mcp, gui: specs from sources, spec-blind suites, and the bugs they found
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:
swaitsandClaude Opus 5.5 committed 2026-10-14 10:13:05 -06:00
1 parent 5b8910a1cd
commit 3f20df9700
42 files changed
+4636 -363

No files matched your search

+8 -14
View File
@@ -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>>(())
})
}
+3 -191
View File
@@ -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
View File
@@ -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))
+3 -3
View File
@@ -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>,
+197
View File
@@ -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);
}
}