Files
wanderbooks/crates/ledger-config/src/codec.rs
T
swaits 02cb7f0254 feat(parties): an email and default terms per side (P4b S4.2)
A customer's email is copied onto an invoice's bill-to snapshot at issue,
and a party's default terms (one as our customer, one as our vendor) are
the fallback a document's due date takes when the document itself names
none (A-13, rules.md B-39). Both are journaled as party.updated facts and
guarded by documents_v2: in books that have not declared it the write is
refused with the upgrade remedy and journaling nothing, and replay gives
the same directory back.

An email must be one plain address: one @, no spaces, no full TIN (the
party text rule already refuses nine digits). Terms are typed and already
canonical, so there is no second spelling to check. A party.updated naming
a field this version does not know is now NewerJournal, never corrupt
(B-27): an older version must say update, not damage; a known field with a
malformed value is still corrupt.
2026-10-27 03:44:24 -06:00

876 lines
32 KiB
Rust

//! Settings files as text in and out of the books (plan/served.md 5.4).
//!
//! Books keep their settings in the journal (`ledger_core::settings`).
//! A `wanderbooks.toml` is text to import from and export to: the server
//! never reads one once the books hold their settings.
//!
//! ```text
//! GOAL: move a company's settings file into its books without changing
//! how a single setting reads, and write them back out as a file.
//! SOURCE: plan/served.md I-3, 5.4 (toml_round_trip_zero_events); the
//! settings file's own rules (crate::parse).
//! INVARIANTS:
//! 1. An import is exactly the changes that make the books' settings
//! what the file (and config.json's receipt policy, if given) says:
//! keys the file sets and the books don't, or set differently, are
//! set; keys the books set and the file doesn't are cleared. Nothing
//! else.
//! 2. NEVER an import that reads differently than the file: the
//! settings after it give the same company, receipt policy, cash
//! flow roles, asset tag prefix, party policy and time zone that
//! the file gives the server today (crate::parse,
//! crate::effective_receipt_policy_text), or the import is refused.
//! 3. Nothing in the file is dropped silently: what isn't a books
//! setting is a note (where it goes instead) or unknown (not
//! imported; the caller shows it before anything is journaled).
//! 4. Round trip: importing an export changes nothing (identity values
//! are exported masked, and a masked value imports as "unchanged").
//! ```
use crate::{BooksSettings, ReceiptPolicy};
use ledger_core::settings::{self, Change, KEYS, Kind, Sensitivity, Settings, Source};
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::fmt::Write as _;
/// What importing a settings file would do.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Import {
/// The changes that make the books' settings what the file says
/// (INVARIANT 1); empty when they already are.
pub changes: Vec<Change>,
/// What the file says that isn't a books setting, and where it goes
/// instead (INVARIANT 3).
pub notes: Vec<String>,
/// Entries the books don't know: not imported (INVARIANT 3).
pub unknown: Vec<String>,
/// `[vault] key_file`, if the file sets one: not a books setting (a
/// book in the data root keeps its own key: `books adopt --vault-key`
/// brings it in).
pub vault_key_file: Option<String>,
/// The file's comments, in order: a settings file's notes (where a
/// value came from, what it waits on) are not settings, so they are
/// listed for the caller to keep elsewhere (a convention, a decision, a
/// setting's note) rather than dropped unseen (INVARIANT 3).
pub comments: Vec<String>,
}
/// The comments of TOML text: what follows each `#` outside a string, in
/// order, trimmed, blank ones left out.
pub fn comments(text: &str) -> Vec<String> {
let mut out = Vec::new();
for line in text.lines() {
let mut quote: Option<char> = None;
let mut escaped = false;
for (i, c) in line.char_indices() {
match quote {
Some(q) => {
if escaped {
escaped = false;
} else if c == '\\' && q == '"' {
escaped = true;
} else if c == q {
quote = None;
}
}
None if c == '"' || c == '\'' => quote = Some(c),
None if c == '#' => {
let rest = line[i + 1..].trim();
if !rest.is_empty() {
out.push(rest.to_string());
}
break;
}
None => {}
}
}
}
out
}
/// A TOML value as the JSON a setting takes: strings stay strings; a
/// number, boolean or date in a text setting reads as its TOML spelling;
/// arrays and tables convert element by element.
fn json_of(v: &toml::Value) -> Value {
match v {
toml::Value::String(s) => json!(s),
toml::Value::Integer(i) => json!(i.to_string()),
toml::Value::Float(f) => json!(f.to_string()),
toml::Value::Boolean(b) => json!(b.to_string()),
toml::Value::Datetime(d) => json!(d.to_string()),
toml::Value::Array(a) => Value::Array(a.iter().map(json_of).collect()),
toml::Value::Table(t) => {
Value::Object(t.iter().map(|(k, v)| (k.clone(), json_of(v))).collect())
}
}
}
fn key_at(table: &str, name: &str) -> Option<&'static settings::Key> {
KEYS.iter().find(|k| k.toml == (table, name))
}
/// What importing `text` (a `wanderbooks.toml`) and, if given,
/// `config_json` (its `receipt_policy` wins over the file's receipt
/// settings, as it does for the server) would change in books holding
/// `current`, kept in `base_currency`.
pub fn import(
text: &str,
config_json: Option<&str>,
current: &Settings,
base_currency: &str,
) -> Result<Import, String> {
// The file as the server reads it today: what the import must match.
let legacy = crate::parse(text)?;
let legacy_policy = crate::effective_receipt_policy_text(config_json, &legacy)?;
let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
let mut out = Import {
comments: comments(text),
..Import::default()
};
let mut wanted: BTreeMap<&'static str, Value> = BTreeMap::new();
for (section, body) in &table {
let toml::Value::Table(body) = body else {
out.unknown.push(format!("{section} (outside any table)"));
continue;
};
for (name, v) in body {
match (section.as_str(), name.as_str()) {
("company", "base_currency") => {
let c = v.as_str().unwrap_or_default().trim().to_uppercase();
if c != base_currency {
return Err(format!(
"[company] base_currency {c:?}: these books are kept in {base_currency}, fixed when they were created"
));
}
out.notes.push(format!(
"[company] base_currency: the books keep it themselves ({base_currency}, fixed when they were created)"
));
}
("books", "data_dir") => out.notes.push(
"[books] data_dir: where the books live is the server's to know, not a setting; dropped".into(),
),
("vault", "key_file") => {
out.vault_key_file = v.as_str().map(str::to_string);
out.notes.push(
"[vault] key_file: not a books setting (a book in the data root keeps its own key: adopt with --vault-key naming that file); dropped".into(),
);
}
("viewer", n) => out.notes.push(format!(
"[viewer] {n}: the agent's preference, not a books setting; dropped (record it as a convention if it matters)"
)),
(s, n) => match key_at(s, n) {
Some(k) => {
wanted.insert(k.name, json_of(v));
}
None => out.unknown.push(format!("[{s}] {n}")),
},
}
}
}
if let Some(raw) = config_json {
let j: Value = serde_json::from_str(raw).map_err(|e| format!("bad config: {e}"))?;
let obj = j.as_object().ok_or("bad config: not a JSON object")?;
for (k, v) in obj {
match k.as_str() {
"receipt_policy" => {
// Its policy replaces the file's, whole (crate::
// effective_receipt_policy_text).
let p: ReceiptPolicy = serde_json::from_value(v.clone())
.map_err(|e| format!("bad config: {e}"))?;
wanted.insert("policy.evidence.threshold", json!(p.require_receipts_above));
wanted.insert(
"policy.evidence.required_accounts",
json!(p.receipt_required_accounts),
);
wanted.insert(
"policy.evidence.exempt_accounts",
json!(p.receipt_exempt_accounts),
);
}
"base_currency" => {
let c = v.as_str().unwrap_or_default().trim().to_uppercase();
if c != base_currency {
return Err(format!(
"config.json base_currency {c:?}: these books are kept in {base_currency}"
));
}
}
other => out.unknown.push(format!("config.json {other}")),
}
}
}
// Canonical values, and the diff against what the books hold. An
// identity value exported masked ("**-***5893") never imports: it is
// what the books hold already when its last four match, else a note,
// and either way the books' own value stays (INVARIANT 4).
let mut canon: BTreeMap<&'static str, Value> = BTreeMap::new();
let mut keep: Vec<&'static str> = Vec::new();
for (name, v) in &wanted {
let k = settings::key(name).expect("taken from KEYS");
let masked =
k.sensitivity == Sensitivity::Identity && v.as_str().is_some_and(|s| s.contains('*'));
if masked {
let same = current.get(name).is_some_and(|r| {
v.as_str() == Some(settings::display(k, &r.value, false).as_str())
});
if !same {
out.notes.push(format!(
"[{}] {}: masked in this file, so not imported; set it with books.set_setting",
k.toml.0, k.toml.1
));
}
keep.push(k.name);
continue;
}
let value =
settings::check(k, v).map_err(|e| format!("[{}] {}: {e}", k.toml.0, k.toml.1))?;
// An empty list is the same as no list: not a change.
if value.as_array().is_some_and(Vec::is_empty) && k.kind != Kind::Members {
continue;
}
canon.insert(k.name, value);
}
for k in KEYS {
let have = current.get(k.name).map(|r| &r.value);
match (canon.get(k.name), have) {
(Some(want), Some(have)) if want == have => {}
(Some(want), _) => out.changes.push(Change {
key: k.name.into(),
value: Some(want.clone()),
}),
(None, Some(_)) if keep.contains(&k.name) => {}
(None, Some(_)) => out.changes.push(Change {
key: k.name.into(),
value: None,
}),
(None, None) => {}
}
}
// INVARIANT 2: the books, after the import, read as the file does.
let mut next = current.clone();
let probe = Source {
decided_by: "import".into(),
decided_on: chrono::NaiveDate::from_ymd_opt(2000, 1, 1).expect("a date"),
citation: None,
note: None,
};
next.apply(&out.changes, &probe, u64::MAX)?;
next.check_whole()?;
let ours = books_settings(&next, base_currency);
let our_policy = receipt_policy(&next);
let differs = differences(&legacy, &legacy_policy, &ours, &our_policy);
if !differs.is_empty() {
return Err(format!(
"importing would read these settings differently than the file does: {}",
differs.join(", ")
));
}
Ok(out)
}
/// Where two readings of the settings differ, by field name.
fn differences(
a: &BooksSettings,
ap: &ReceiptPolicy,
b: &BooksSettings,
bp: &ReceiptPolicy,
) -> Vec<&'static str> {
let (ca, cb) = (&a.company, &b.company);
let threshold = |p: &ReceiptPolicy| crate::decimal(p.require_receipts_above.trim());
[
("company name", ca.name == cb.name),
(
"fiscal year start",
ca.fiscal_year_start == cb.fiscal_year_start,
),
("members", ca.members == cb.members),
("accounting basis", ca.basis == cb.basis),
("tax form", ca.tax_form == cb.tax_form),
("close_into", ca.close_into == cb.close_into),
("close_sweep", ca.close_sweep == cb.close_sweep),
("receipt threshold", threshold(ap) == threshold(bp)),
(
"receipt required accounts",
ap.receipt_required_accounts == bp.receipt_required_accounts,
),
(
"receipt exempt accounts",
ap.receipt_exempt_accounts == bp.receipt_exempt_accounts,
),
("cash accounts", a.cash_accounts == b.cash_accounts),
(
"financing accounts",
a.financing_accounts == b.financing_accounts,
),
(
"investing accounts",
a.investing_accounts == b.investing_accounts,
),
(
"asset tag prefix",
a.asset_tag_prefix() == b.asset_tag_prefix(),
),
("parties", a.parties == b.parties),
("time zone", a.timezone == b.timezone),
]
.into_iter()
.filter(|(_, same)| !same)
.map(|(n, _)| n)
.collect()
}
/// The settings as the server and the viewer use them, from the books'
/// own (`ledger_core::settings`): what [`crate::parse`] gives for a
/// settings file. `[vault] key_file` is never a books setting.
pub fn books_settings(s: &Settings, base_currency: &str) -> BooksSettings {
BooksSettings {
company: s.company(base_currency),
receipt_threshold: s.receipt_threshold().map(str::to_string),
receipt_required_accounts: s.receipt_required_accounts(),
receipt_exempt_accounts: s.receipt_exempt_accounts(),
cash_accounts: s.cash_accounts(),
financing_accounts: s.financing_accounts(),
investing_accounts: s.investing_accounts(),
asset_tag_prefix: s.asset_tag_prefix().map(str::to_string),
parties: s.parties(),
timezone: s.timezone().map(str::to_string),
}
}
/// The receipt policy the books' own settings set.
pub fn receipt_policy(s: &Settings) -> ReceiptPolicy {
ReceiptPolicy {
require_receipts_above: s
.receipt_threshold()
.unwrap_or(settings::DEFAULT_THRESHOLD)
.to_string(),
receipt_required_accounts: s.receipt_required_accounts(),
receipt_exempt_accounts: s.receipt_exempt_accounts(),
}
}
/// A scalar as TOML writes it (strings escaped and quoted the TOML way).
fn toml_scalar(v: toml::Value) -> String {
let mut t = toml::Table::new();
t.insert("x".to_string(), v);
let out = toml::to_string(&t).unwrap_or_default();
let out = out.trim_end();
out.strip_prefix("x = ").unwrap_or(out).to_string()
}
/// `v` on one line, as TOML's inline form: a table is `{ k = v }`, so a
/// list of payment methods stays inside its table instead of becoming
/// `[[payment_methods]]` sections under a later table.
fn inline_toml(v: &Value) -> String {
match v {
Value::String(s) => toml_scalar(toml::Value::String(s.clone())),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::Array(a) => format!(
"[{}]",
a.iter().map(inline_toml).collect::<Vec<_>>().join(", ")
),
Value::Object(o) => format!(
"{{ {} }}",
o.iter()
.map(|(k, v)| format!("{} = {}", toml_key(k), inline_toml(v)))
.collect::<Vec<_>>()
.join(", ")
),
Value::Null => "\"\"".to_string(),
}
}
/// A TOML key: bare when it can be, quoted when it can't.
fn toml_key(k: &str) -> String {
if !k.is_empty()
&& k.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
k.to_string()
} else {
toml_scalar(toml::Value::String(k.to_string()))
}
}
/// One TOML line `name = value`, the value spelled as TOML.
fn toml_line(name: &str, v: &Value) -> String {
format!("{name} = {}\n", inline_toml(v))
}
fn provenance(out: &mut String, r: &settings::Recorded) {
let s = &r.source;
let _ = writeln!(
out,
"# {}, {} (journal line {})",
s.decided_by, s.decided_on, r.seq
);
for extra in [&s.citation, &s.note].into_iter().flatten() {
for line in extra.lines() {
let _ = writeln!(out, "# {line}");
}
}
}
/// The books' settings as a settings file (`wanderbooks.toml` text):
/// every setting set, under its table, with who decided it and when as a
/// comment; identity values masked (INVARIANT 4).
pub fn export(s: &Settings, base_currency: &str) -> String {
let mut out = String::new();
let _ = writeln!(
out,
"# wanderbooks settings, exported from the books{}.",
s.last_seq()
.map(|q| format!(" (through journal line {q})"))
.unwrap_or_default()
);
out.push_str(
"# The books keep their own settings: this file is a copy, and editing it\n# changes nothing (books.set_setting changes them).\n",
);
for table in ["company", "books", "invoicing", "bills"] {
// The invoicing and bills tables are written only once a key is
// set: an empty table says nothing.
if !["company", "books"].contains(&table) && !s.iter().any(|(k, _)| k.toml.0 == table) {
continue;
}
let _ = write!(out, "\n[{table}]\n");
if table == "company" {
let _ = writeln!(
out,
"# Fixed when the books were created.\nbase_currency = {:?}",
base_currency
);
}
let mut members = None;
for (k, r) in s.iter().filter(|(k, _)| k.toml.0 == table) {
if k.kind == Kind::Members {
members = Some((k, r));
continue;
}
provenance(&mut out, r);
let shown = match k.sensitivity {
Sensitivity::Identity => Value::String(settings::display(k, &r.value, false)),
Sensitivity::Public => r.value.clone(),
};
let _ = writeln!(out, "{}", toml_line(k.toml.1, &shown));
}
if let Some((_, r)) = members {
let _ = writeln!(out);
provenance(&mut out, r);
for m in r.value.as_array().into_iter().flatten() {
out.push_str("[[company.members]]\n");
for f in [
"name",
"share",
"capital_account",
"distribution_account",
"files_jointly_with",
] {
if let Some(v) = m.get(f) {
let _ = writeln!(out, "{}", toml_line(f, v));
}
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const FERNHILL: &str = r#"
[company]
name = "Fernhill Labs LLC"
entity_type = "LLC, multi-member, member-managed"
tax_classification = "partnership"
base_currency = "USD"
fiscal_year_start = "01-01"
formation_state = "UT"
formed = "2026-10-04"
state_entity_number = "14745117-0160"
ein = "28-8525893"
irs_name_control = "WALN"
duns = "149976972"
address = "100 Example Way, Midvale, UT 84047"
registered_agent = "Tobin Harlow"
partnership_representative = "Tobin Harlow"
cpa = "Casey Rowe"
[[company.members]]
name = "Tobin Harlow"
share = "50%"
capital_account = "3000"
files_jointly_with = "Morgan Ellis"
[[company.members]]
name = "Morgan Ellis"
share = "50%"
capital_account = "3001"
files_jointly_with = "Tobin Harlow"
[books]
data_dir = "books-data"
accounting_basis = "accrual"
receipt_threshold = "0.00"
cash_accounts = ["1000"]
financing_accounts = ["2100"]
parties = "require"
[viewer]
open_at_start = "ask"
"#;
fn src() -> Source {
Source {
decided_by: "Owner".into(),
decided_on: chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(),
citation: None,
note: Some("imported".into()),
}
}
fn imported(text: &str) -> Settings {
let i = import(text, None, &Settings::default(), "USD").unwrap();
let mut s = Settings::default();
let changes = s.check_changes(&i.changes, &src()).unwrap();
s.apply(&changes, &src(), 1).unwrap();
s
}
/// INVARIANTS 1-3 on a real company's file.
#[test]
fn a_real_file_imports_whole() {
let i = import(FERNHILL, None, &Settings::default(), "USD").unwrap();
assert!(i.unknown.is_empty(), "{:?}", i.unknown);
assert_eq!(i.notes.len(), 3, "{:?}", i.notes);
// 14 company facts, the members, and 5 [books] settings.
assert_eq!(i.changes.len(), 20);
let s = imported(FERNHILL);
assert_eq!(s.get("company.ein").unwrap().value, json!("28-8525893"));
assert_eq!(s.parties(), crate::PartyPolicy::Require);
// Already imported: nothing to change.
let again = import(FERNHILL, None, &s, "USD").unwrap();
assert!(again.changes.is_empty(), "{:?}", again.changes);
}
/// INVARIANT 4: an export imports as no change at all.
#[test]
fn export_round_trips() {
let s = imported(FERNHILL);
let text = export(&s, "USD");
assert!(text.contains("ein = \"**-***5893\""), "{text}");
assert!(!text.contains("2215893"), "{text}");
let back = import(&text, None, &s, "USD").unwrap();
assert!(back.changes.is_empty(), "{:?}\n{text}", back.changes);
assert!(back.unknown.is_empty(), "{:?}", back.unknown);
// And the export reads, as a settings file, as the books do.
assert_eq!(
crate::parse(&text).unwrap().company.name,
"Fernhill Labs LLC"
);
}
/// INVARIANT 4 for the invoicing settings (P4b S4.2): the two new
/// tables export, a payment method with its bank details, its link and
/// its QR flag included, and the file imports as no change at all.
#[test]
fn the_invoicing_settings_round_trip() {
let file = format!(
"{FERNHILL}\n[invoicing]\n\
number_prefix = \"ACME-\"\n\
first_number = 1000\n\
default_terms = \"2/10 net 30\"\n\
footer = \"Thank you.\"\n\
unsent_days = 2\n\
reminder_days = 14\n\
payment_methods = [{{kind = \"ach\", label = \"Bank transfer\", lines = [\"Routing 123456789\"]}}, {{kind = \"link\", label = \"Card\", lines = [], link = \"https://pay.example/acme\", qr = true}}]\n\
\n[bills]\n\
remind_days = 7\n\
discount_warn_days = 3\n"
);
let s = imported(&file);
assert_eq!(s.get("invoicing.first_number").unwrap().value, json!(1000));
assert_eq!(
s.get("invoicing.default_terms").unwrap().value,
json!("2/10 net 30")
);
let text = export(&s, "USD");
assert!(text.contains("[invoicing]"), "{text}");
assert!(text.contains("[bills]"), "{text}");
assert!(text.contains("payment_methods = ["), "{text}");
assert!(text.contains("qr = true"), "{text}");
let back = import(&text, None, &s, "USD").unwrap();
assert!(back.changes.is_empty(), "{:?}\n{text}", back.changes);
assert!(back.unknown.is_empty(), "{:?}", back.unknown);
// Into empty books: the same values, in one spelling.
let fresh = import(&text, None, &Settings::default(), "USD").unwrap();
let mut other = Settings::default();
other.apply(&fresh.changes, &src(), 1).unwrap();
for name in [
"invoicing.number_prefix",
"invoicing.first_number",
"invoicing.default_terms",
"invoicing.footer",
"invoicing.unsent_days",
"invoicing.reminder_days",
"invoicing.payment_methods",
"bills.remind_days",
"bills.discount_warn_days",
] {
assert_eq!(
other.get(name).map(|r| &r.value),
s.get(name).map(|r| &r.value),
"{name}"
);
}
}
#[test]
fn the_tax_category_policy_round_trips() {
let file = FERNHILL.replace(
"parties = \"require\"\n",
"parties = \"require\"\nrequire_tax_category = \"required\"\n",
);
let s = imported(&file);
assert_eq!(
s.require_tax_category(),
ledger_core::settings::TaxCategoryPolicy::Refuse
);
let text = export(&s, "USD");
assert!(text.contains("require_tax_category = \"refuse\""), "{text}");
let back = import(&text, None, &s, "USD").unwrap();
assert!(back.changes.is_empty(), "{:?}\n{text}", back.changes);
let cleared = import(FERNHILL, None, &s, "USD").unwrap();
assert_eq!(
cleared.changes,
[Change {
key: "policy.require_tax_category".into(),
value: None
}]
);
}
/// INVARIANT 1: keys the file leaves out are cleared.
#[test]
fn what_the_file_leaves_out_is_cleared() {
let s = imported(FERNHILL);
let i = import("[company]\nname = \"Fernhill Labs LLC\"\n", None, &s, "USD").unwrap();
assert!(
i.changes
.iter()
.any(|c| c.key == "company.members" && c.value.is_none())
);
assert!(!i.changes.iter().any(|c| c.key == "company.name"));
}
/// INVARIANT 3, and the currency is the books'.
#[test]
fn unknown_and_currency() {
let i = import(
"[company]\nfax = \"x\"\n[extra]\na = 1\n",
None,
&Settings::default(),
"USD",
)
.unwrap();
assert_eq!(i.unknown, ["[company] fax", "[extra] a"]);
assert!(
import(
"[company]\nbase_currency = \"EUR\"\n",
None,
&Settings::default(),
"USD"
)
.is_err()
);
}
/// config.json's receipt policy wins, whole, as it does for the server.
#[test]
fn config_json_policy_wins() {
let j = r#"{"receipt_policy": {"require_receipts_above": "10.00", "receipt_exempt_accounts": ["6999"]}}"#;
let i = import(
"[books]\nreceipt_threshold = \"0.00\"\n",
Some(j),
&Settings::default(),
"USD",
)
.unwrap();
let mut s = Settings::default();
s.apply(&i.changes, &src(), 1).unwrap();
let p = receipt_policy(&s);
assert_eq!(p.require_receipts_above, "10.00");
assert_eq!(p.receipt_exempt_accounts, ["6999"]);
}
/// INVARIANT 2: a value the books would read differently is refused
/// (here: a time zone the file names that isn't one).
#[test]
fn never_reads_differently() {
assert!(
import(
"[company]\ntimezone = \"Nowhere/Land\"\n",
None,
&Settings::default(),
"USD"
)
.is_err()
);
assert!(
import(
"[company]\nfiscal_year_start = \"07-01\"\n",
None,
&Settings::default(),
"USD"
)
.is_ok()
);
}
/// A comment is what follows a `#` outside a string: a `#` inside a
/// basic string (even after an escaped quote) or a literal string
/// (where a backslash escapes nothing) is text; blank comments are
/// left out.
#[test]
fn comments_are_what_follows_a_hash_outside_strings() {
let text = "# top\n\
name = \"a # not\" # one\n\
esc = \"q \\\" # still\" # two\n\
lit = 'c:\\' # three\n\
#\n\
x = 1 # \n\
y = 2#five\n";
assert_eq!(comments(text), ["top", "one", "two", "three", "five"]);
assert!(comments("a = 'x' \"y\"\n").is_empty());
}
/// The import lists the file's comments (INVARIANT 3).
#[test]
fn an_import_lists_the_comments() {
let i = import(
"# Kept settings\n[company]\nname = \"X LLC\" # from the certificate\n",
None,
&Settings::default(),
"USD",
)
.unwrap();
assert_eq!(i.comments, ["Kept settings", "from the certificate"]);
}
/// config.json's base_currency must be the books'.
#[test]
fn config_json_currency_is_the_books() {
let ok = import(
"",
Some(r#"{"base_currency": " usd "}"#),
&Settings::default(),
"USD",
)
.unwrap();
assert!(ok.changes.is_empty() && ok.unknown.is_empty(), "{ok:?}");
let err = import(
"",
Some(r#"{"base_currency": "EUR"}"#),
&Settings::default(),
"USD",
)
.unwrap_err();
assert!(err.contains("config.json base_currency \"EUR\""), "{err}");
}
/// INVARIANT 4: a masked identity value never imports. It keeps the
/// books' own value either way, with a note only when its last four
/// differ from what the books hold.
#[test]
fn a_masked_identity_keeps_the_books_value() {
let s = imported(FERNHILL);
let masked =
|ein: &str| FERNHILL.replace("ein = \"28-8525893\"", &format!("ein = \"{ein}\""));
let note =
"[company] ein: masked in this file, so not imported; set it with books.set_setting";
let same = import(&masked("**-***5893"), None, &s, "USD").unwrap();
assert!(same.changes.is_empty(), "{:?}", same.changes);
assert!(!same.notes.iter().any(|n| n == note), "{:?}", same.notes);
let other = import(&masked("**-***0000"), None, &s, "USD").unwrap();
assert!(other.changes.is_empty(), "{:?}", other.changes);
assert!(other.notes.iter().any(|n| n == note), "{:?}", other.notes);
// Into books that hold none: still not imported, and noted.
let empty = import(
"[company]\nein = \"**-***5893\"\n",
None,
&Settings::default(),
"USD",
)
.unwrap();
assert!(empty.changes.is_empty(), "{:?}", empty.changes);
assert_eq!(empty.notes, [note]);
}
/// An empty list is no list: not a change.
#[test]
fn an_empty_list_is_no_change() {
let i = import(
"[books]\ncash_accounts = []\nfinancing_accounts = []\n",
None,
&Settings::default(),
"USD",
)
.unwrap();
assert!(i.changes.is_empty(), "{:?}", i.changes);
}
/// A value the file changes is a change, exactly that one.
#[test]
fn a_changed_value_is_one_change() {
let s = imported(FERNHILL);
let text = FERNHILL.replace("\"Fernhill Labs LLC\"", "\"Other Labs LLC\"");
let i = import(&text, None, &s, "USD").unwrap();
assert_eq!(
i.changes,
[Change {
key: "company.name".into(),
value: Some(json!("Other Labs LLC")),
}]
);
}
/// INVARIANT 2's check names each field read differently.
#[test]
fn differences_name_each_field() {
let a = BooksSettings::default();
let p = ReceiptPolicy::default();
assert!(differences(&a, &p, &a, &p).is_empty());
let mut b = a.clone();
b.company.name = "Other".into();
b.timezone = Some("America/Denver".into());
let mut q = p.clone();
q.require_receipts_above = format!("{}0", p.require_receipts_above);
// The default asset tag prefix is made from the company's name.
let named = ["company name", "asset tag prefix", "time zone"];
assert_eq!(differences(&a, &p, &b, &q), named);
q.require_receipts_above = "1.00".into();
assert_eq!(
differences(&a, &p, &b, &q),
[
"company name",
"receipt threshold",
"asset tag prefix",
"time zone"
]
);
}
/// An export says, for each setting, who decided it, when, and its
/// note.
#[test]
fn an_export_keeps_each_settings_source() {
let text = export(&imported(FERNHILL), "USD");
assert!(
text.contains("\n# Owner, 2026-10-17 (journal line 1)\n# imported\n"),
"{text}"
);
}
}