#![cfg(feature = "ag-rledger")]
use serde_json::Value;
use std::path::PathBuf;
use std::process::Command;
fn ag_rledger() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_ag-rledger"))
}
fn write_fixture(dir: &std::path::Path, name: &str, contents: &str) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, contents).expect("write fixture");
path
}
fn run(args: &[&str]) -> (i32, Value) {
let output = Command::new(ag_rledger())
.args(args)
.output()
.expect("spawn ag-rledger");
let code = output.status.code().expect("exit code");
let stdout = String::from_utf8_lossy(&output.stdout);
let envelope: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("envelope is not JSON ({e}): {stdout}"));
(code, envelope)
}
const GOOD_LEDGER: &str = "\
2024-01-01 open Assets:Cash
2024-01-01 open Equity:Opening
2024-01-02 * \"Opening balance\"
Assets:Cash 100.00 USD
Equity:Opening -100.00 USD
";
const BAD_LEDGER: &str = "\
2024-01-01 open Assets:Cash
2024-01-02 * \"Unbalanced\"
Assets:Cash 100.00 USD
Equity:Opening -90.00 USD
";
#[test]
fn check_good_file_exits_zero_with_ok_envelope() {
let tmp = tempfile::tempdir().unwrap();
let file = write_fixture(tmp.path(), "good.beancount", GOOD_LEDGER);
let (code, env) = run(&["check", file.to_str().unwrap(), "--json"]);
assert_eq!(code, 0, "good file should exit 0: {env}");
assert_eq!(env["ok"], Value::Bool(true));
assert_eq!(env["exit_code"], 0);
assert_eq!(env["result"]["data"]["error_count"], 0);
}
#[test]
fn check_bad_file_exits_nonzero() {
let tmp = tempfile::tempdir().unwrap();
let file = write_fixture(tmp.path(), "bad.beancount", BAD_LEDGER);
let (code, env) = run(&["check", file.to_str().unwrap(), "--json"]);
assert_ne!(code, 0, "unbalanced file should exit non-zero: {env}");
assert_eq!(env["exit_code"], 1);
assert!(
env["result"]["data"]["error_count"]
.as_u64()
.is_some_and(|n| n >= 1),
"expected at least one error: {env}"
);
}
#[test]
fn check_missing_file_maps_to_not_found() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("nope.beancount");
let (code, env) = run(&["check", missing.to_str().unwrap(), "--json"]);
assert_eq!(code, 3, "missing file should map to NOT_FOUND: {env}");
assert_eq!(env["ok"], Value::Bool(false));
assert_eq!(env["error"]["code"], "FILE_NOT_FOUND");
}
#[test]
fn query_returns_structured_json() {
let tmp = tempfile::tempdir().unwrap();
let file = write_fixture(tmp.path(), "good.beancount", GOOD_LEDGER);
let (code, env) = run(&[
"query",
file.to_str().unwrap(),
"SELECT account, sum(position) GROUP BY account",
"--format",
"json",
]);
assert_eq!(code, 0, "query should exit 0: {env}");
assert_eq!(env["ok"], Value::Bool(true));
let rows = &env["result"]["data"]["rows"];
assert!(rows.is_array(), "expected rows array: {env}");
assert_eq!(rows.as_array().unwrap().len(), 2, "two accounts: {env}");
}
#[test]
fn report_balances_returns_json_data() {
let tmp = tempfile::tempdir().unwrap();
let file = write_fixture(tmp.path(), "good.beancount", GOOD_LEDGER);
let (code, env) = run(&[
"report",
file.to_str().unwrap(),
"balances",
"--format",
"json",
]);
assert_eq!(code, 0, "report should exit 0: {env}");
assert_eq!(env["ok"], Value::Bool(true));
assert!(
env["result"]["data"].is_array(),
"balances data should be a JSON array: {env}"
);
}
#[test]
fn check_alias_c_works() {
let tmp = tempfile::tempdir().unwrap();
let file = write_fixture(tmp.path(), "good.beancount", GOOD_LEDGER);
let (code, env) = run(&["c", file.to_str().unwrap(), "--json"]);
assert_eq!(code, 0, "alias `c` should behave like check: {env}");
assert_eq!(env["ok"], Value::Bool(true));
}
#[test]
fn extract_bool_flag_before_positional_keeps_positional() {
let tmp = tempfile::tempdir().unwrap();
let csv = write_fixture(
tmp.path(),
"bank.csv",
"Date,Description,Amount\n2024-01-02,Coffee,-4.50\n",
);
let (code, env) = run(&["extract", "--invert-sign", csv.to_str().unwrap()]);
assert_ne!(
env["error"]["code"], "MISSING_FILE",
"--invert-sign should not swallow the positional file: {env}"
);
assert_ne!(code, 2, "should not be a usage error: {env}");
}
#[test]
fn extract_no_header_flag_before_positional_keeps_positional() {
let tmp = tempfile::tempdir().unwrap();
let csv = write_fixture(tmp.path(), "bank.csv", "2024-01-02,Coffee,-4.50\n");
let (_code, env) = run(&["extract", "--no-header", csv.to_str().unwrap()]);
assert_ne!(
env["error"]["code"], "MISSING_FILE",
"--no-header should not swallow the positional file: {env}"
);
}
#[test]
fn query_without_file_uses_default_not_positional() {
let tmp = tempfile::tempdir().unwrap();
let file = write_fixture(tmp.path(), "good.beancount", GOOD_LEDGER);
let output = Command::new(ag_rledger())
.args([
"query",
"SELECT account, sum(position) GROUP BY account",
"--format",
"json",
])
.env("RLEDGER_FILE", file.to_str().unwrap())
.output()
.expect("spawn ag-rledger");
let stdout = String::from_utf8_lossy(&output.stdout);
let env: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("envelope is not JSON ({e}): {stdout}"));
assert_ne!(
env["error"]["code"], "FILE_NOT_FOUND",
"query text must not be treated as a file path: {env}"
);
}
#[test]
fn query_with_ledger_positional_still_uses_it_as_file() {
let tmp = tempfile::tempdir().unwrap();
let file = write_fixture(tmp.path(), "good.beancount", GOOD_LEDGER);
let (code, env) = run(&[
"query",
file.to_str().unwrap(),
"SELECT account, sum(position) GROUP BY account",
"--format",
"json",
]);
assert_eq!(
code, 0,
"query with explicit ledger positional should run: {env}"
);
assert_eq!(env["ok"], Value::Bool(true));
}
#[test]
fn add_without_yes_or_dry_run_errors_cleanly() {
let tmp = tempfile::tempdir().unwrap();
let file = write_fixture(tmp.path(), "ledger.beancount", GOOD_LEDGER);
let before = std::fs::read_to_string(&file).unwrap();
let output = Command::new(ag_rledger())
.args([
"add",
file.to_str().unwrap(),
"--quick",
"Coffee Shop",
"Morning coffee",
"Expenses:Food",
"4.50 USD",
"Assets:Cash",
])
.stdin(std::process::Stdio::null())
.output()
.expect("spawn ag-rledger");
let code = output.status.code().expect("exit code");
let stdout = String::from_utf8_lossy(&output.stdout);
let env: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("envelope is not JSON ({e}): {stdout}"));
assert_eq!(
env["ok"],
Value::Bool(false),
"should be an error envelope: {env}"
);
assert_eq!(
code, 2,
"confirmation-required should map to USAGE (2): {env}"
);
assert_eq!(env["error"]["code"], "CONFIRMATION_REQUIRED", "{env}");
let after = std::fs::read_to_string(&file).unwrap();
assert_eq!(
before, after,
"ledger must not be mutated without confirmation"
);
}
#[test]
fn add_without_quick_errors_cleanly_no_panic() {
let tmp = tempfile::tempdir().unwrap();
let file = write_fixture(tmp.path(), "ledger.beancount", GOOD_LEDGER);
let before = std::fs::read_to_string(&file).unwrap();
for confirm in ["--yes", "--dry-run"] {
let output = Command::new(ag_rledger())
.args(["add", file.to_str().unwrap(), confirm])
.stdin(std::process::Stdio::null())
.output()
.expect("spawn ag-rledger");
let code = output.status.code().expect("exit code");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let env: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("envelope is not JSON ({e}): {stdout}"));
assert!(
!stderr.contains("panicked"),
"{confirm}: must not panic; stderr:\n{stderr}"
);
assert_eq!(env["ok"], Value::Bool(false), "{confirm}: {env}");
assert_eq!(
code, 2,
"{confirm}: missing --quick should map to USAGE (2): {env}"
);
assert_eq!(
env["error"]["code"], "MISSING_QUICK_ARGS",
"{confirm}: {env}"
);
}
assert_eq!(before, std::fs::read_to_string(&file).unwrap());
}
#[test]
fn add_dry_run_previews_without_mutating() {
let tmp = tempfile::tempdir().unwrap();
let file = write_fixture(tmp.path(), "ledger.beancount", GOOD_LEDGER);
let before = std::fs::read_to_string(&file).unwrap();
let output = Command::new(ag_rledger())
.args([
"add",
file.to_str().unwrap(),
"--dry-run",
"--quick",
"Coffee Shop",
"Morning coffee",
"Expenses:Food",
"4.50 USD",
"Assets:Cash",
])
.stdin(std::process::Stdio::null())
.output()
.expect("spawn ag-rledger");
let code = output.status.code().expect("exit code");
let stdout = String::from_utf8_lossy(&output.stdout);
let env: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("envelope is not JSON ({e}): {stdout}"));
assert_eq!(code, 0, "dry-run should succeed: {env}");
assert_eq!(env["ok"], Value::Bool(true));
let after = std::fs::read_to_string(&file).unwrap();
assert_eq!(before, after, "dry-run must not mutate the ledger");
}
#[test]
fn root_command_tree_is_self_documenting() {
let (code, env) = run(&[]);
assert_eq!(code, 0);
assert_eq!(env["ok"], Value::Bool(true));
assert_eq!(env["result"]["compatibility"]["engine"], "rustledger");
}