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
198 lines
6.5 KiB
Rust
198 lines
6.5 KiB
Rust
//! 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);
|
|
}
|
|
}
|