use std::collections::BTreeMap;
use time::Date;
use crate::canonical::{Canonical, CanonicalWriter};
use crate::hash::{Hash, tag, tagged};
use crate::merkle::MerkleLog;
pub const MAX_SEGMENT_LEN: usize = 64;
pub const MAX_DEPTH: usize = 16;
pub const SEPARATOR: char = ':';
#[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 {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)]
#[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>,
}
impl Account {
#[must_use]
pub fn new(path: AccountPath, opened_on: Date) -> Self {
Self {
path,
kind: None,
opened_on,
closed_on: None,
}
}
#[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 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));
}
}
pub(crate) fn encode_date(w: &mut CanonicalWriter, d: Date) {
w.u16(d.year().unsigned_abs().min(u32::from(u16::MAX)) as u16);
w.bool(d.year() < 0);
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);
}
}
#[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()).unwrap_or(u32::MAX));
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
.index
.keys()
.filter(|p| p.is_under(&path) && **p != 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)
}
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(())
}
#[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 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 (position, record) in records.into_iter().enumerate() {
let expected = u32::try_from(position).unwrap_or(u32::MAX);
if record.id.index() != expected {
return Err(AccountError::NotDense {
expected,
found: record.id.index(),
});
}
registry.register(record.account)?;
}
Ok(registry)
}
#[must_use]
pub fn commitment(&self) -> Hash {
let leaves: Vec<Hash> = self
.records()
.iter()
.map(|record| {
let mut w = CanonicalWriter::new();
record.encode(&mut w);
tagged(tag::ACCOUNT_BINDING_V1, &w.finish())
})
.collect();
MerkleLog::from_leaves(leaves).root()
}
#[must_use]
pub fn descendants_of(&self, prefix: &AccountPath) -> Vec<AccountId> {
self.index
.iter()
.filter(|(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 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());
}
}