use std::collections::BTreeMap;
use time::Date;
use crate::balance::Balance;
use crate::canonical::{Canonical, CanonicalWriter};
use crate::hash::{Hash, tag, tagged};
use crate::merkle::{InclusionProof, MerkleLog, TreeHead};
pub const MAX_SEGMENT_LEN: usize = 64;
pub const MAX_DEPTH: usize = 16;
pub const SEPARATOR: char = ':';
pub const MAX_ACCOUNTS: usize = u32::MAX as usize;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum AccountError {
#[error("account path is empty")]
Empty,
#[error("account path has an empty segment at position {position}")]
EmptySegment {
position: usize,
},
#[error("account path segment {position} exceeds {MAX_SEGMENT_LEN} characters")]
SegmentTooLong {
position: usize,
},
#[error("account path is deeper than {MAX_DEPTH} segments")]
TooDeep,
#[error("account path segment {position} contains a control character")]
ControlCharacter {
position: usize,
},
#[error("account {path} is already registered")]
AlreadyRegistered {
path: AccountPath,
},
#[error("account {id} is not registered")]
UnknownAccount {
id: AccountId,
},
#[error("stored account bindings are not dense: expected index {expected}, found {found}")]
NotDense {
expected: u32,
found: u32,
},
#[error("account registry is full: all {MAX_ACCOUNTS} handles have been issued")]
RegistryFull,
#[error("account {path} closes on {closed} before it opens on {opened}")]
ClosedBeforeOpened {
path: AccountPath,
opened: Date,
closed: Date,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct AccountPath {
segments: Vec<String>,
}
#[cfg(feature = "serde")]
impl serde::Serialize for AccountPath {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for AccountPath {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = <std::borrow::Cow<'_, str> as serde::Deserialize>::deserialize(d)?;
Self::parse(&s).map_err(serde::de::Error::custom)
}
}
impl AccountPath {
pub fn parse(s: &str) -> Result<Self, AccountError> {
if s.is_empty() {
return Err(AccountError::Empty);
}
let segments: Vec<&str> = s.split(SEPARATOR).collect();
Self::from_segments(segments)
}
pub fn from_segments<S: AsRef<str>>(
segments: impl IntoIterator<Item = S>,
) -> Result<Self, AccountError> {
let raw: Vec<String> = segments
.into_iter()
.map(|s| s.as_ref().to_owned())
.collect();
if raw.is_empty() {
return Err(AccountError::Empty);
}
if raw.len() > MAX_DEPTH {
return Err(AccountError::TooDeep);
}
for (position, seg) in raw.iter().enumerate() {
if seg.is_empty() {
return Err(AccountError::EmptySegment { position });
}
if seg.chars().count() > MAX_SEGMENT_LEN {
return Err(AccountError::SegmentTooLong { position });
}
if seg.chars().any(char::is_control) {
return Err(AccountError::ControlCharacter { position });
}
}
Ok(Self { segments: raw })
}
#[must_use]
pub fn segments(&self) -> &[String] {
&self.segments
}
#[must_use]
pub fn depth(&self) -> usize {
self.segments.len()
}
#[must_use]
pub fn parent(&self) -> Option<Self> {
if self.segments.len() <= 1 {
return None;
}
let mut segments = self.segments.clone();
segments.pop();
Some(Self { segments })
}
#[must_use]
pub fn ancestors(&self) -> Vec<Self> {
let mut out = Vec::new();
let mut current = self.parent();
while let Some(p) = current {
current = p.parent();
out.push(p);
}
out.reverse();
out
}
#[must_use]
pub fn is_under(&self, other: &Self) -> bool {
if self.segments.len() < other.segments.len() {
return false;
}
self.segments
.iter()
.zip(other.segments.iter())
.all(|(a, b)| a == b)
}
#[must_use]
pub fn content_hash(&self) -> Hash {
tagged(tag::ACCOUNT_V1, &self.to_canonical_bytes())
}
}
impl Canonical for AccountPath {
fn encode(&self, w: &mut CanonicalWriter) {
w.seq(self.segments.iter(), |w, s| {
w.str(s);
});
}
}
impl std::fmt::Display for AccountPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut first = true;
for seg in &self.segments {
if !first {
f.write_str(":")?;
}
f.write_str(seg)?;
first = false;
}
Ok(())
}
}
impl std::str::FromStr for AccountPath {
type Err = AccountError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum AccountKind {
Asset,
Liability,
Equity,
Income,
Expense,
}
impl AccountKind {
#[must_use]
pub const fn normal_side(self) -> crate::posting::Direction {
use crate::posting::Direction;
match self {
Self::Asset | Self::Expense => Direction::Debit,
Self::Liability | Self::Equity | Self::Income => Direction::Credit,
}
}
fn discriminant(self) -> u8 {
match self {
Self::Asset => 0,
Self::Liability => 1,
Self::Equity => 2,
Self::Income => 3,
Self::Expense => 4,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum BalanceLimit {
#[default]
Unlimited,
NoCreditBalance,
NoDebitBalance,
}
impl BalanceLimit {
#[must_use]
pub fn permits<const P: u8>(self, balance: &Balance<P>) -> bool {
match self {
Self::Unlimited => true,
Self::NoCreditBalance => balance.debits >= balance.credits,
Self::NoDebitBalance => balance.credits >= balance.debits,
}
}
#[must_use]
pub const fn is_unlimited(self) -> bool {
matches!(self, Self::Unlimited)
}
const fn discriminant(self) -> u8 {
match self {
Self::Unlimited => 0,
Self::NoCreditBalance => 1,
Self::NoDebitBalance => 2,
}
}
}
impl std::fmt::Display for BalanceLimit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Unlimited => "unlimited",
Self::NoCreditBalance => "no credit balance",
Self::NoDebitBalance => "no debit balance",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AccountId(u32);
impl AccountId {
#[must_use]
pub const fn index(self) -> u32 {
self.0
}
#[must_use]
pub const fn from_index(index: u32) -> Self {
Self(index)
}
}
impl std::fmt::Display for AccountId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "#{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Account {
pub path: AccountPath,
pub kind: Option<AccountKind>,
pub opened_on: Date,
pub closed_on: Option<Date>,
pub limit: BalanceLimit,
}
impl Account {
#[must_use]
pub fn new(path: AccountPath, opened_on: Date) -> Self {
Self {
path,
kind: None,
opened_on,
closed_on: None,
limit: BalanceLimit::Unlimited,
}
}
#[must_use]
pub fn with_kind(mut self, kind: AccountKind) -> Self {
self.kind = Some(kind);
self
}
#[must_use]
pub fn closing_on(mut self, date: Date) -> Self {
self.closed_on = Some(date);
self
}
#[must_use]
pub fn limited_to(mut self, limit: BalanceLimit) -> Self {
self.limit = limit;
self
}
#[must_use]
pub fn is_open_on(&self, date: Date) -> bool {
date >= self.opened_on && self.closed_on.is_none_or(|c| date <= c)
}
}
impl Canonical for Account {
fn encode(&self, w: &mut CanonicalWriter) {
self.path.encode(w);
w.option(self.kind.as_ref(), |w, k| {
w.u8(k.discriminant());
});
encode_date(w, self.opened_on);
w.option(self.closed_on.as_ref(), |w, d| encode_date(w, *d));
w.u8(self.limit.discriminant());
}
}
pub(crate) fn encode_date(w: &mut CanonicalWriter, d: Date) {
w.i32(d.year());
w.u8(u8::from(d.month()));
w.u8(d.day());
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AccountRecord {
pub id: AccountId,
pub account: Account,
}
impl Canonical for AccountRecord {
fn encode(&self, w: &mut CanonicalWriter) {
w.u32(self.id.index());
self.account.encode(w);
}
}
#[must_use]
pub fn account_binding_leaf(record: &AccountRecord) -> Hash {
let mut w = CanonicalWriter::new();
record.encode(&mut w);
tagged(tag::ACCOUNT_BINDING_V1, &w.finish())
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AccountBindingProof {
pub record: AccountRecord,
pub proof: InclusionProof,
}
impl AccountBindingProof {
#[must_use]
pub fn id(&self) -> AccountId {
self.record.id
}
#[must_use]
pub fn account(&self) -> &Account {
&self.record.account
}
#[must_use]
pub fn verify(&self, accounts: &TreeHead) -> bool {
self.proof.leaf_index == u64::from(self.record.id.index())
&& self
.proof
.verify(&account_binding_leaf(&self.record), accounts)
}
}
#[derive(Debug, Clone, Default)]
pub struct AccountRegistry {
accounts: Vec<Account>,
index: BTreeMap<AccountPath, AccountId>,
child_count: Vec<u32>,
}
impl AccountRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, account: Account) -> Result<AccountId, AccountError> {
if let Some(closed) = account.closed_on
&& closed < account.opened_on
{
return Err(AccountError::ClosedBeforeOpened {
path: account.path.clone(),
opened: account.opened_on,
closed,
});
}
if self.index.contains_key(&account.path) {
return Err(AccountError::AlreadyRegistered { path: account.path });
}
let id =
AccountId(u32::try_from(self.accounts.len()).map_err(|_| AccountError::RegistryFull)?);
let path = account.path.clone();
for ancestor in path.ancestors() {
if let Some(parent_id) = self.index.get(&ancestor)
&& let Some(count) = self.child_count.get_mut(parent_id.0 as usize)
{
*count = count.saturating_add(1);
}
}
let existing_children = self.descendant_range(&path).count();
self.accounts.push(account);
self.child_count
.push(u32::try_from(existing_children).unwrap_or(u32::MAX));
self.index.insert(path, id);
Ok(id)
}
fn descendant_range<'a>(
&'a self,
prefix: &'a AccountPath,
) -> impl Iterator<Item = (&'a AccountPath, &'a AccountId)> {
self.index
.range(prefix.clone()..)
.skip_while(move |(path, _)| *path == prefix)
.take_while(move |(path, _)| path.is_under(prefix))
}
pub fn register_path(
&mut self,
path: &str,
opened_on: Date,
) -> Result<AccountId, AccountError> {
self.register(Account::new(AccountPath::parse(path)?, opened_on))
}
#[must_use]
pub fn id_of(&self, path: &AccountPath) -> Option<AccountId> {
self.index.get(path).copied()
}
#[must_use]
pub fn get(&self, id: AccountId) -> Option<&Account> {
self.accounts.get(id.0 as usize)
}
#[must_use]
pub fn is_leaf(&self, id: AccountId) -> bool {
self.child_count.get(id.0 as usize).copied().unwrap_or(0) == 0
}
#[must_use]
pub fn len(&self) -> usize {
self.accounts.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.accounts.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (AccountId, &Account)> {
self.index
.values()
.filter_map(|id| self.get(*id).map(|a| (*id, a)))
}
pub fn close(&mut self, id: AccountId, on: Date) -> Result<(), AccountError> {
let Some(account) = self.accounts.get_mut(id.index() as usize) else {
return Err(AccountError::UnknownAccount { id });
};
if on < account.opened_on {
return Err(AccountError::ClosedBeforeOpened {
path: account.path.clone(),
opened: account.opened_on,
closed: on,
});
}
account.closed_on = Some(on);
Ok(())
}
pub fn reopen(&mut self, id: AccountId) -> Result<(), AccountError> {
let Some(account) = self.accounts.get_mut(id.index() as usize) else {
return Err(AccountError::UnknownAccount { id });
};
account.closed_on = None;
Ok(())
}
pub fn set_limit(&mut self, id: AccountId, limit: BalanceLimit) -> Result<(), AccountError> {
let Some(account) = self.accounts.get_mut(id.index() as usize) else {
return Err(AccountError::UnknownAccount { id });
};
account.limit = limit;
Ok(())
}
#[must_use]
pub fn limit_of(&self, id: AccountId) -> BalanceLimit {
self.get(id).map_or(BalanceLimit::Unlimited, |a| a.limit)
}
#[must_use]
pub fn records(&self) -> Vec<AccountRecord> {
self.accounts
.iter()
.enumerate()
.map(|(i, account)| AccountRecord {
id: AccountId(u32::try_from(i).unwrap_or(u32::MAX)),
account: account.clone(),
})
.collect()
}
pub fn restore(&mut self, record: AccountRecord) -> Result<(), AccountError> {
let next = u32::try_from(self.accounts.len()).unwrap_or(u32::MAX);
if let Some(existing) = self.get(record.id) {
if existing.path != record.account.path {
return Err(AccountError::AlreadyRegistered {
path: existing.path.clone(),
});
}
if let Some(closed) = record.account.closed_on
&& closed < record.account.opened_on
{
return Err(AccountError::ClosedBeforeOpened {
path: record.account.path,
opened: record.account.opened_on,
closed,
});
}
if let Some(slot) = self.accounts.get_mut(record.id.index() as usize) {
*slot = record.account;
}
return Ok(());
}
if record.id.index() != next {
return Err(AccountError::NotDense {
expected: next,
found: record.id.index(),
});
}
self.register(record.account).map(|_| ())
}
pub fn from_records(
records: impl IntoIterator<Item = AccountRecord>,
) -> Result<Self, AccountError> {
let mut records: Vec<_> = records.into_iter().collect();
records.sort_by_key(|r| r.id.index());
let mut registry = Self::new();
for record in records {
registry.restore(record)?;
}
Ok(registry)
}
#[must_use]
pub fn commitment(&self) -> TreeHead {
self.binding_log().head()
}
fn binding_log(&self) -> MerkleLog {
MerkleLog::from_leaves(self.records().iter().map(account_binding_leaf).collect())
}
#[must_use]
pub fn prove_binding(&self, id: AccountId) -> Option<AccountBindingProof> {
let record = AccountRecord {
id,
account: self.get(id)?.clone(),
};
let proof = self
.binding_log()
.inclusion_proof(u64::from(id.index()))
.ok()?;
Some(AccountBindingProof { record, proof })
}
#[must_use]
pub fn descendants_of(&self, prefix: &AccountPath) -> Vec<AccountId> {
self.index
.range(prefix.clone()..)
.take_while(|(path, _)| path.is_under(prefix))
.map(|(_, id)| *id)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::date;
fn reg() -> AccountRegistry {
AccountRegistry::new()
}
#[test]
fn parses_and_displays_a_path() {
let p = AccountPath::parse("Assets:Bank:Main").expect("valid");
assert_eq!(p.depth(), 3);
assert_eq!(p.to_string(), "Assets:Bank:Main");
}
#[test]
fn rejects_malformed_paths() {
assert_eq!(AccountPath::parse(""), Err(AccountError::Empty));
assert!(matches!(
AccountPath::parse("Assets::Main"),
Err(AccountError::EmptySegment { position: 1 })
));
assert!(matches!(
AccountPath::parse("Assets:"),
Err(AccountError::EmptySegment { position: 1 })
));
assert!(matches!(
AccountPath::parse(&"x".repeat(MAX_SEGMENT_LEN + 1)),
Err(AccountError::SegmentTooLong { position: 0 })
));
assert!(matches!(
AccountPath::parse("Assets:Ba\nk"),
Err(AccountError::ControlCharacter { position: 1 })
));
}
#[test]
fn rejects_excessive_depth() {
let deep = (0..=MAX_DEPTH).map(|i| i.to_string()).collect::<Vec<_>>();
assert_eq!(AccountPath::from_segments(deep), Err(AccountError::TooDeep));
}
#[test]
fn computes_ancestry() {
let p = AccountPath::parse("A:B:C").expect("valid");
assert_eq!(p.parent().expect("has parent").to_string(), "A:B");
let ancestors: Vec<String> = p.ancestors().iter().map(ToString::to_string).collect();
assert_eq!(ancestors, vec!["A", "A:B"]);
assert!(AccountPath::parse("A").expect("valid").parent().is_none());
}
#[test]
fn is_under_matches_whole_segments_only() {
let child = AccountPath::parse("Assets:Bank").expect("valid");
let prefix = AccountPath::parse("Assets").expect("valid");
let decoy = AccountPath::parse("Asset").expect("valid");
assert!(child.is_under(&prefix));
assert!(child.is_under(&child));
assert!(!child.is_under(&decoy));
}
#[test]
fn registers_and_looks_up() {
let mut r = reg();
let id = r
.register_path("Assets:Bank", date!(2026 - 01 - 01))
.expect("registers");
let path = AccountPath::parse("Assets:Bank").expect("valid");
assert_eq!(r.id_of(&path), Some(id));
assert_eq!(r.get(id).expect("exists").path, path);
assert_eq!(r.len(), 1);
}
#[test]
fn rejects_duplicate_registration() {
let mut r = reg();
r.register_path("Assets", date!(2026 - 01 - 01))
.expect("registers");
assert!(matches!(
r.register_path("Assets", date!(2026 - 01 - 01)),
Err(AccountError::AlreadyRegistered { .. })
));
}
#[test]
fn rejects_closing_before_opening() {
let mut r = reg();
let acct = Account::new(
AccountPath::parse("A").expect("valid"),
date!(2026 - 06 - 01),
)
.closing_on(date!(2026 - 01 - 01));
assert!(matches!(
r.register(acct),
Err(AccountError::ClosedBeforeOpened { .. })
));
}
#[test]
fn a_parent_registered_first_stops_being_a_leaf() {
let mut r = reg();
let parent = r
.register_path("Assets", date!(2026 - 01 - 01))
.expect("registers");
assert!(r.is_leaf(parent));
r.register_path("Assets:Bank", date!(2026 - 01 - 01))
.expect("registers");
assert!(!r.is_leaf(parent));
}
#[test]
fn a_parent_registered_later_is_not_a_leaf() {
let mut r = reg();
let child = r
.register_path("Assets:Bank", date!(2026 - 01 - 01))
.expect("registers");
let parent = r
.register_path("Assets", date!(2026 - 01 - 01))
.expect("registers");
assert!(!r.is_leaf(parent));
assert!(r.is_leaf(child));
}
#[test]
fn open_window_is_inclusive() {
let a = Account::new(
AccountPath::parse("A").expect("valid"),
date!(2026 - 01 - 01),
)
.closing_on(date!(2026 - 12 - 31));
assert!(!a.is_open_on(date!(2025 - 12 - 31)));
assert!(a.is_open_on(date!(2026 - 01 - 01)));
assert!(a.is_open_on(date!(2026 - 12 - 31)));
assert!(!a.is_open_on(date!(2027 - 01 - 01)));
}
#[test]
fn account_without_closing_date_stays_open() {
let a = Account::new(
AccountPath::parse("A").expect("valid"),
date!(2026 - 01 - 01),
);
assert!(a.is_open_on(date!(2999 - 01 - 01)));
}
#[test]
fn descendants_are_returned_in_path_order() {
let mut r = reg();
r.register_path("Assets:Cash", date!(2026 - 01 - 01))
.expect("registers");
r.register_path("Assets:Bank", date!(2026 - 01 - 01))
.expect("registers");
r.register_path("Income:Sales", date!(2026 - 01 - 01))
.expect("registers");
let prefix = AccountPath::parse("Assets").expect("valid");
let found: Vec<String> = r
.descendants_of(&prefix)
.into_iter()
.filter_map(|id| r.get(id).map(|a| a.path.to_string()))
.collect();
assert_eq!(found, vec!["Assets:Bank", "Assets:Cash"]);
}
#[test]
fn normal_sides_follow_the_accounting_equation() {
use crate::posting::Direction;
assert_eq!(AccountKind::Asset.normal_side(), Direction::Debit);
assert_eq!(AccountKind::Expense.normal_side(), Direction::Debit);
assert_eq!(AccountKind::Liability.normal_side(), Direction::Credit);
assert_eq!(AccountKind::Equity.normal_side(), Direction::Credit);
assert_eq!(AccountKind::Income.normal_side(), Direction::Credit);
}
#[test]
fn the_commitment_changes_when_a_handle_moves() {
let build = |paths: [&str; 2]| {
let mut r = reg();
for p in paths {
r.register_path(p, date!(2026 - 01 - 01))
.expect("registers");
}
r
};
let forward = build(["Assets:Cash", "Income:Sales"]);
let swapped = build(["Income:Sales", "Assets:Cash"]);
assert_ne!(forward.commitment(), swapped.commitment());
assert_eq!(
forward.commitment(),
build(["Assets:Cash", "Income:Sales"]).commitment()
);
}
#[test]
fn every_binding_proves_against_the_commitment() {
let mut r = reg();
for i in 0..17 {
r.register_path(&format!("Assets:A{i}"), date!(2026 - 01 - 01))
.expect("registers");
}
let root = r.commitment();
for (id, account) in r.iter() {
let proof = r.prove_binding(id).expect("registered");
assert!(proof.verify(&root), "binding for {id} must prove");
assert_eq!(proof.account().path, account.path);
}
assert!(r.prove_binding(AccountId::from_index(99)).is_none());
}
#[test]
fn a_binding_proof_does_not_survive_an_edit_to_the_account() {
let mut r = reg();
let id = r
.register_path("Assets:Cash", date!(2026 - 01 - 01))
.expect("registers");
r.register_path("Income:Sales", date!(2026 - 01 - 01))
.expect("registers");
let root = r.commitment();
let proof = r.prove_binding(id).expect("registered");
assert!(proof.verify(&root));
let mut renamed = proof.clone();
renamed.record.account.path = AccountPath::parse("Assets:Slush").expect("valid");
assert!(!renamed.verify(&root));
let mut reopened = proof;
reopened.record.account.closed_on = None;
reopened.record.account.opened_on = date!(2020 - 01 - 01);
assert!(!reopened.verify(&root));
}
#[test]
fn a_limit_constrains_the_net_and_ignores_the_gross() {
use crate::money::Amount;
use crate::posting::Direction;
let mut heavy = Balance::<2>::ZERO;
heavy
.add(Direction::Debit, Amount::<2>::from_minor(1_000))
.expect("ok");
heavy
.add(Direction::Credit, Amount::<2>::from_minor(1_000))
.expect("ok");
for limit in [
BalanceLimit::Unlimited,
BalanceLimit::NoCreditBalance,
BalanceLimit::NoDebitBalance,
] {
assert!(limit.permits(&heavy), "{limit} should permit a zero net");
}
let mut overdrawn = Balance::<2>::ZERO;
overdrawn
.add(Direction::Credit, Amount::<2>::from_minor(1))
.expect("ok");
assert!(BalanceLimit::Unlimited.permits(&overdrawn));
assert!(!BalanceLimit::NoCreditBalance.permits(&overdrawn));
assert!(BalanceLimit::NoDebitBalance.permits(&overdrawn));
}
#[test]
fn a_limit_is_part_of_what_the_account_is() {
let mut r = reg();
let id = r
.register_path("Assets:Cash", date!(2026 - 01 - 01))
.expect("registers");
let before = r.commitment();
r.set_limit(id, BalanceLimit::NoCreditBalance)
.expect("registered");
assert_eq!(r.limit_of(id), BalanceLimit::NoCreditBalance);
assert_ne!(r.commitment(), before);
assert!(matches!(
r.set_limit(AccountId::from_index(9), BalanceLimit::Unlimited),
Err(AccountError::UnknownAccount { .. })
));
assert_eq!(
r.limit_of(AccountId::from_index(9)),
BalanceLimit::Unlimited
);
}
#[test]
fn restoring_a_binding_updates_its_master_data_but_never_its_path() {
let mut r = reg();
let id = r
.register_path("Assets:Cash", date!(2026 - 01 - 01))
.expect("registers");
let mut record = AccountRecord {
id,
account: Account::new(
AccountPath::parse("Assets:Cash").expect("valid"),
date!(2026 - 01 - 01),
),
};
r.restore(record.clone()).expect("unchanged");
assert_eq!(r.len(), 1);
record.account = record
.account
.clone()
.with_kind(AccountKind::Asset)
.closing_on(date!(2026 - 12 - 31))
.limited_to(BalanceLimit::NoCreditBalance);
r.restore(record.clone()).expect("master data moves");
let stored = r.get(id).expect("registered");
assert_eq!(stored.kind, Some(AccountKind::Asset));
assert_eq!(stored.closed_on, Some(date!(2026 - 12 - 31)));
assert_eq!(stored.limit, BalanceLimit::NoCreditBalance);
assert_eq!(r.len(), 1, "an update must not issue a second handle");
let mut rebound = record.clone();
rebound.account.path = AccountPath::parse("Assets:Slush").expect("valid");
assert!(matches!(
r.restore(rebound),
Err(AccountError::AlreadyRegistered { .. })
));
assert_eq!(
r.get(id).expect("registered").path.to_string(),
"Assets:Cash"
);
let mut inverted = record;
inverted.account.closed_on = Some(date!(2020 - 01 - 01));
assert!(matches!(
r.restore(inverted),
Err(AccountError::ClosedBeforeOpened { .. })
));
}
#[test]
fn path_hash_is_stable_and_distinguishing() {
let a = AccountPath::parse("A:B").expect("valid");
let b = AccountPath::parse("A:B").expect("valid");
let c = AccountPath::parse("AB").expect("valid");
assert_eq!(a.content_hash(), b.content_hash());
assert_ne!(a.content_hash(), c.content_hash());
}
}