rustledger_core/identifiers.rs
1//! Domain-typed identifiers: [`Account`], [`Currency`], [`Tag`], [`Link`].
2//!
3//! These newtype wrappers around [`InternedStr`] give the type system
4//! enough vocabulary to distinguish the different kinds of identifier
5//! the beancount AST carries. Pre-newtype, every identifier was just
6//! an `InternedStr` — passing an account where a currency was
7//! expected (or vice versa) compiled fine, and the bug surfaced
8//! only at runtime via wrong-but-validly-shaped string matching.
9//! Now the same mistake is a type error.
10//!
11//! # Design
12//!
13//! Each newtype is a transparent wrapper:
14//!
15//! - `Deref<Target = str>` so calls like `account.starts_with("Assets:")`
16//! work without `.as_str()` everywhere.
17//! - `AsRef<str>` and [`Borrow<str>`](std::borrow::Borrow) so `HashMap` lookups by `&str`
18//! keep working (`some_map.get("Assets:Bank")` where the map is
19//! keyed by [`Account`]).
20//! - `PartialEq` against `str` / `&str` / `String` / `InternedStr` /
21//! the newtype's own type, so `account == "Assets:Bank"` keeps
22//! reading naturally without coercion.
23//! - `From<&str>`, `From<String>`, `From<InternedStr>` for
24//! construction at call sites that have a string and need the
25//! typed form.
26//! - `Hash` delegates to the inner `InternedStr`'s hash, so
27//! `HashMap<Account, V>` and `HashMap<InternedStr, V>` produce
28//! the same bucketing for the same underlying string.
29//!
30//! What you DON'T get for free is cross-newtype assignment:
31//!
32//! ```compile_fail
33//! # use rustledger_core::{Account, Currency};
34//! fn want_currency(_: Currency) {}
35//! let acct = Account::from("Assets:Bank");
36//! want_currency(acct); // ← type error
37//! ```
38//!
39//! Conversions between newtypes are deliberate (`Currency::from(account.into_interned())`)
40//! so the compiler can flag accidental crossings.
41//!
42//! # When to use which
43//!
44//! All four newtypes — [`Currency`], [`Account`], [`Tag`], and
45//! [`Link`] — are fully plumbed through the AST, including
46//! `MetaValue` variants:
47//!
48//! - [`Currency`]: `Commodity.currency`, `Open.currencies` entries,
49//! `Amount.currency`, `CostSpec.currency`, `Price.currency`,
50//! `IncompleteAmount::CurrencyOnly`, `MetaValue::Currency`.
51//! - [`Account`]: `Open.account`, `Close.account`, `Balance.account`,
52//! `Pad.account` / `source_account`, `Note.account`,
53//! `Document.account`, `Posting.account`, `MetaValue::Account`.
54//! - [`Tag`]: `Transaction.tags` entries, `pushtag`/`poptag` stack,
55//! `Document.tags`, `MetaValue::Tag`.
56//! - [`Link`]: `Transaction.links` entries, `Document.links`,
57//! `MetaValue::Link`.
58//!
59//! The plugin wire-format type `rustledger_plugin_types::MetaValueData`
60//! deliberately keeps `String` payloads — `plugin-types` is a minimal
61//! WASM-compatible crate that does not depend on `rustledger-core`,
62//! and plugins run without access to the workspace interner anyway.
63//! The convert boundary
64//! (`rustledger_plugin::convert::from_wrapper`) wraps the incoming
65//! strings in fresh `Arc<str>`s; the cross-file canonicalization to
66//! one `Arc<str>` per identifier string happens later in
67//! `rustledger_loader::dedup::reintern_directives`, which walks both
68//! AST identifier fields and `MetaValue::*` payloads inside metadata.
69
70use crate::InternedStr;
71#[cfg(feature = "rkyv")]
72use crate::intern::AsInternedStr;
73
74macro_rules! domain_newtype {
75 ($name:ident, $kind:literal) => {
76 #[doc = concat!("Domain-typed identifier for a ", $kind, ". See the [module docs](crate::identifiers) for rationale.")]
77 #[derive(Debug, Clone, Eq)]
78 #[cfg_attr(
79 feature = "rkyv",
80 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
81 )]
82 #[repr(transparent)]
83 pub struct $name(
84 #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))] InternedStr,
85 );
86
87 impl $name {
88 /// Construct from anything that can become an `InternedStr`.
89 #[must_use]
90 pub fn new(s: impl Into<InternedStr>) -> Self {
91 Self(s.into())
92 }
93
94 /// Borrow the underlying string slice.
95 #[must_use]
96 pub fn as_str(&self) -> &str {
97 self.0.as_str()
98 }
99
100 /// Borrow the underlying `InternedStr`. Useful when interfacing
101 /// with APIs that still take untyped interned strings.
102 #[must_use]
103 pub const fn as_interned(&self) -> &InternedStr {
104 &self.0
105 }
106
107 /// Unwrap to the underlying `InternedStr`, discarding the
108 /// domain tag. Use deliberately — this is the explicit
109 /// "I'm crossing types on purpose" escape hatch.
110 #[must_use]
111 pub fn into_interned(self) -> InternedStr {
112 self.0
113 }
114
115 /// Pointer-equality on the underlying `Arc<str>`.
116 ///
117 /// `true` iff both values point at the same interner allocation.
118 /// Used by cross-file dedup tests to assert that the loader's
119 /// re-interning pass canonicalized the storage; not a substitute
120 /// for `==` (which is the byte-equality semantics callers want).
121 #[must_use]
122 pub fn ptr_eq(&self, other: &Self) -> bool {
123 self.0.ptr_eq(&other.0)
124 }
125
126 /// Mutable access to the underlying `InternedStr`.
127 /// Used by the loader's cross-file interning pass
128 /// (`rustledger_loader::dedup`) to canonicalize the
129 /// `Arc` after merging directives from multiple files —
130 /// the value semantics don't change, but the storage is
131 /// re-pointed at the workspace-wide interner's copy.
132 pub const fn as_interned_mut(&mut self) -> &mut InternedStr {
133 &mut self.0
134 }
135 }
136
137 impl PartialEq for $name {
138 fn eq(&self, other: &Self) -> bool {
139 self.0 == other.0
140 }
141 }
142
143 impl PartialEq<str> for $name {
144 fn eq(&self, other: &str) -> bool {
145 self.0 == *other
146 }
147 }
148
149 impl PartialEq<&str> for $name {
150 fn eq(&self, other: &&str) -> bool {
151 self.0 == **other
152 }
153 }
154
155 impl PartialEq<String> for $name {
156 fn eq(&self, other: &String) -> bool {
157 self.0 == *other
158 }
159 }
160
161 impl PartialEq<InternedStr> for $name {
162 fn eq(&self, other: &InternedStr) -> bool {
163 self.0 == *other
164 }
165 }
166
167 impl PartialEq<$name> for &str {
168 fn eq(&self, other: &$name) -> bool {
169 other.0 == **self
170 }
171 }
172
173 impl PartialEq<$name> for str {
174 fn eq(&self, other: &$name) -> bool {
175 other.0 == *self
176 }
177 }
178
179 impl PartialEq<$name> for InternedStr {
180 fn eq(&self, other: &$name) -> bool {
181 *self == other.0
182 }
183 }
184
185 impl std::hash::Hash for $name {
186 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
187 self.0.hash(state);
188 }
189 }
190
191 impl std::cmp::PartialOrd for $name {
192 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
193 Some(self.cmp(other))
194 }
195 }
196
197 impl std::cmp::Ord for $name {
198 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
199 self.0.cmp(&other.0)
200 }
201 }
202
203 impl std::ops::Deref for $name {
204 type Target = str;
205 fn deref(&self) -> &str {
206 self.0.as_str()
207 }
208 }
209
210 impl AsRef<str> for $name {
211 fn as_ref(&self) -> &str {
212 self.0.as_str()
213 }
214 }
215
216 impl std::borrow::Borrow<str> for $name {
217 fn borrow(&self) -> &str {
218 self.0.as_str()
219 }
220 }
221
222 impl std::fmt::Display for $name {
223 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224 std::fmt::Display::fmt(&self.0, f)
225 }
226 }
227
228 impl From<&str> for $name {
229 fn from(s: &str) -> Self {
230 Self(InternedStr::from(s))
231 }
232 }
233
234 impl From<String> for $name {
235 fn from(s: String) -> Self {
236 Self(InternedStr::from(s))
237 }
238 }
239
240 impl From<&String> for $name {
241 fn from(s: &String) -> Self {
242 Self(InternedStr::from(s.as_str()))
243 }
244 }
245
246 impl From<InternedStr> for $name {
247 fn from(s: InternedStr) -> Self {
248 Self(s)
249 }
250 }
251
252 impl From<&InternedStr> for $name {
253 fn from(s: &InternedStr) -> Self {
254 Self(s.clone())
255 }
256 }
257
258 impl From<&$name> for $name {
259 fn from(s: &$name) -> Self {
260 s.clone()
261 }
262 }
263
264 impl Default for $name {
265 fn default() -> Self {
266 Self(InternedStr::default())
267 }
268 }
269
270 impl serde::Serialize for $name {
271 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
272 self.0.serialize(serializer)
273 }
274 }
275
276 impl<'de> serde::Deserialize<'de> for $name {
277 fn deserialize<D: serde::Deserializer<'de>>(
278 deserializer: D,
279 ) -> Result<Self, D::Error> {
280 Ok(Self(InternedStr::deserialize(deserializer)?))
281 }
282 }
283
284 // rkyv archive is `#[derive]`'d above using the field
285 // attribute `#[rkyv(with = AsInternedStr)]` — same wrapper
286 // pattern `Posting.account` (and every other `InternedStr`
287 // field) uses. That goes through `ArchivedString` via the
288 // `AsInternedStr` adapter, picking up bytecheck/CheckBytes
289 // for free.
290 };
291}
292
293domain_newtype!(Account, "beancount account name (e.g. `Assets:Cash:USD`)");
294domain_newtype!(Currency, "currency code (e.g. `USD`, `EUR`, `AAPL`)");
295domain_newtype!(Tag, "beancount tag (e.g. `#travel`)");
296domain_newtype!(Link, "beancount link (e.g. `^invoice-2024-01`)");
297
298/// Returns `true` if `child` is the same account as `parent`, or a
299/// sub-account of it.
300///
301/// Beancount's `balance Assets:Bank` assertion (and several other
302/// account-scoped operations) includes postings to `Assets:Bank` AND
303/// `Assets:Bank:Checking`, `Assets:Bank:Savings`, etc. The match is
304/// exact OR `parent + ":"` prefix; a name that merely starts with
305/// `parent`'s string (`Assets:BankAlias`) is NOT a sub-account.
306///
307/// Both arguments are `&str` so callers can mix `Account`, `&str`,
308/// and `String` without coercion. The function does not allocate.
309///
310/// Lifted from
311/// `rustledger-validate::validators::balance::sum_account_and_subaccounts`
312/// and `rustledger-lsp::handlers::code_lens::is_account_or_subaccount`
313/// so both call sites stay aligned under one definition.
314#[must_use]
315pub fn is_subaccount_or_equal(child: &str, parent: &str) -> bool {
316 if child == parent {
317 return true;
318 }
319 let parent_len = parent.len();
320 child.len() > parent_len && child.as_bytes()[parent_len] == b':' && child.starts_with(parent)
321}
322
323/// The five Beancount root account types, in declaration order.
324///
325/// The canonical root-type list for core consumers: the FFI surfaces
326/// (`util.types`, `util.getAccountType`), the query account-type sort order, and
327/// the LSP account-type check all reference this so they cannot drift.
328/// (`rustledger-completion` keeps its own copy — it is a minimal crate that does
329/// not depend on `rustledger-core`.)
330///
331/// These are the default English roots; the `name_*` loader options can rename
332/// them per-ledger, which this constant does not model — do not use it to
333/// classify accounts in a config-aware context.
334pub const ACCOUNT_TYPES: [&str; 5] = ["Assets", "Liabilities", "Equity", "Income", "Expenses"];
335
336/// Whether a bare word is one of the DEFAULT account-type roots.
337///
338/// Named for what it does, not for what it might be mistaken for. It was
339/// `is_account_type`, which promises CLASSIFICATION — and on a ledger with
340/// `option "name_expenses" "Depenses"` it answers `false` for `Depenses` and
341/// `true` for `Expenses`, both wrong as classification. It is inert today
342/// because a bare root is not a valid account name, so nothing downstream can
343/// act on it (#1964); the name was the part that could mislead the next
344/// caller, who might use it where it is not inert.
345///
346/// The single implementation of a question the editor surfaces both asked
347/// separately: `rustledger-lsp` matched against [`ACCOUNT_TYPES`] while
348/// `rustledger-wasm` inlined its own `matches!` over the same five strings.
349/// They agreed only because the two lists happened to hold the same words, and
350/// each carried its own copy of the same five-case test — so a drift would have
351/// left both suites passing while the two surfaces disagreed. That is the
352/// re-derivation shape the duplication review (#1731–#1743) traced every found
353/// bug to, and #1964 is the same shape one layer up.
354///
355/// # Not config-aware, deliberately
356///
357/// This answers for the default English roots only. It exists for the editor's
358/// lexical question — "does this bare word look like an account root, so should
359/// I offer hover/definition affordances?" — asked about a word that is often
360/// typed into a buffer with no ledger loaded at all.
361///
362/// It is NOT the classifier for an account you hold. A bare root is not even a
363/// valid account name (`open Assets` is a parse error), so every real account
364/// reference contains a `:` and should be classified with [`AccountTypes`],
365/// which honors the `name_*` renames.
366#[must_use]
367pub fn is_default_account_root(word: &str) -> bool {
368 ACCOUNT_TYPES.contains(&word)
369}
370
371/// The five beancount account-type kinds, independent of their configured
372/// root names.
373///
374/// `Ord` follows the beancount statement order (assets, liabilities, equity,
375/// income, expenses), so anything grouped by kind — a totals bucket, a report
376/// section — sorts the way a reader expects rather than alphabetically.
377#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
378#[allow(clippy::exhaustive_enums)]
379pub enum AccountTypeKind {
380 /// Assets (debit-normal, balance sheet).
381 Assets,
382 /// Liabilities (credit-normal, balance sheet).
383 Liabilities,
384 /// Equity (credit-normal, balance sheet).
385 Equity,
386 /// Income (credit-normal, income statement).
387 Income,
388 /// Expenses (debit-normal, income statement).
389 Expenses,
390}
391
392/// Config-aware account-type classifier.
393///
394/// beancount lets a ledger rename its five root accounts via the `name_*`
395/// options (e.g. `option "name_income" "Revenue"`). Any consumer that
396/// classifies accounts by root — report section routing, BQL `POSSIGN` /
397/// `ACCOUNT_SORTKEY`, sign conventions — must classify against the
398/// *configured* names, not the [`ACCOUNT_TYPES`] defaults, or renamed
399/// ledgers silently misroute (empty income statements, unflipped signs —
400/// the L5 class). Construct via `Default` for standard names or from the
401/// loader's `Options`.
402#[derive(Debug, Clone, PartialEq, Eq)]
403pub struct AccountTypes {
404 /// Configured root name for Assets.
405 pub assets: String,
406 /// Configured root name for Liabilities.
407 pub liabilities: String,
408 /// Configured root name for Equity.
409 pub equity: String,
410 /// Configured root name for Income.
411 pub income: String,
412 /// Configured root name for Expenses.
413 pub expenses: String,
414}
415
416impl Default for AccountTypes {
417 fn default() -> Self {
418 Self {
419 assets: "Assets".to_string(),
420 liabilities: "Liabilities".to_string(),
421 equity: "Equity".to_string(),
422 income: "Income".to_string(),
423 expenses: "Expenses".to_string(),
424 }
425 }
426}
427
428impl AccountTypeKind {
429 /// The canonical lowercase name for this kind.
430 ///
431 /// THE wire vocabulary: [`account_type`] and the component's
432 /// `util.get-account-type` answer with these exact strings, so anything
433 /// crossing a boundary must use this rather than formatting the enum.
434 /// `format!("{kind:?}").to_lowercase()` happens to agree today and is a
435 /// silent renaming hazard — a variant renamed for Rust's benefit would
436 /// change a wire value nobody meant to touch.
437 #[must_use]
438 pub const fn as_str(self) -> &'static str {
439 match self {
440 Self::Assets => "assets",
441 Self::Liabilities => "liabilities",
442 Self::Equity => "equity",
443 Self::Income => "income",
444 Self::Expenses => "expenses",
445 }
446 }
447}
448
449impl AccountTypes {
450 /// The configured root name for a kind — the inverse of [`Self::kind`].
451 ///
452 /// Lets a consumer that has classified an account render it back using the
453 /// ledger's own vocabulary, instead of hardcoding "Expenses" and mislabeling
454 /// every ledger that sets `option "name_expenses"`.
455 #[must_use]
456 pub fn root_name(&self, kind: AccountTypeKind) -> &str {
457 match kind {
458 AccountTypeKind::Assets => &self.assets,
459 AccountTypeKind::Liabilities => &self.liabilities,
460 AccountTypeKind::Equity => &self.equity,
461 AccountTypeKind::Income => &self.income,
462 AccountTypeKind::Expenses => &self.expenses,
463 }
464 }
465
466 /// Classify `account` by its root segment against the configured names.
467 ///
468 /// Returns `None` for roots matching none of the five (custom types).
469 #[must_use]
470 pub fn kind(&self, account: &str) -> Option<AccountTypeKind> {
471 // Root segment before the first ':', or the whole name when there
472 // is no colon (`split_once` makes the two cases explicit).
473 let root = account.split_once(':').map_or(account, |(root, _)| root);
474 if root == self.assets {
475 Some(AccountTypeKind::Assets)
476 } else if root == self.liabilities {
477 Some(AccountTypeKind::Liabilities)
478 } else if root == self.equity {
479 Some(AccountTypeKind::Equity)
480 } else if root == self.income {
481 Some(AccountTypeKind::Income)
482 } else if root == self.expenses {
483 Some(AccountTypeKind::Expenses)
484 } else {
485 None
486 }
487 }
488
489 /// Balance-sheet account (Assets / Liabilities / Equity)?
490 #[must_use]
491 pub fn is_balance_sheet(&self, account: &str) -> bool {
492 matches!(
493 self.kind(account),
494 Some(AccountTypeKind::Assets | AccountTypeKind::Liabilities | AccountTypeKind::Equity)
495 )
496 }
497
498 /// Income-statement account (Income / Expenses)?
499 #[must_use]
500 pub fn is_income_statement(&self, account: &str) -> bool {
501 matches!(
502 self.kind(account),
503 Some(AccountTypeKind::Income | AccountTypeKind::Expenses)
504 )
505 }
506
507 /// Credit-normal account (Liabilities / Equity / Income) — the set whose
508 /// sign `POSSIGN` flips, matching beancount `get_account_sign` == -1.
509 #[must_use]
510 pub fn is_credit_normal(&self, account: &str) -> bool {
511 matches!(
512 self.kind(account),
513 Some(AccountTypeKind::Liabilities | AccountTypeKind::Equity | AccountTypeKind::Income)
514 )
515 }
516
517 /// Python-parity sort index for `ACCOUNT_SORTKEY`: Assets=0,
518 /// Liabilities=1, Equity=2, Income=3, Expenses=4, custom roots=5.
519 #[must_use]
520 pub fn sort_index(&self, account: &str) -> u8 {
521 match self.kind(account) {
522 Some(AccountTypeKind::Assets) => 0,
523 Some(AccountTypeKind::Liabilities) => 1,
524 Some(AccountTypeKind::Equity) => 2,
525 Some(AccountTypeKind::Income) => 3,
526 Some(AccountTypeKind::Expenses) => 4,
527 None => 5,
528 }
529 }
530}
531
532/// The lowercased root account type for `account` — the segment before the
533/// first `:` — or `"unknown"` if it is not one of [`ACCOUNT_TYPES`].
534#[must_use]
535pub fn account_type(account: &str) -> &'static str {
536 match account.split(':').next() {
537 Some("Assets") => AccountTypeKind::Assets.as_str(),
538 Some("Liabilities") => AccountTypeKind::Liabilities.as_str(),
539 Some("Equity") => AccountTypeKind::Equity.as_str(),
540 Some("Income") => AccountTypeKind::Income.as_str(),
541 Some("Expenses") => AccountTypeKind::Expenses.as_str(),
542 _ => "unknown",
543 }
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 #[test]
551 fn account_type_classifies_roots_and_unknown() {
552 assert_eq!(account_type("Assets:Bank:Checking"), "assets");
553 assert_eq!(account_type("Liabilities:CC"), "liabilities");
554 assert_eq!(account_type("Equity:Opening"), "equity");
555 assert_eq!(account_type("Income:Salary"), "income");
556 assert_eq!(account_type("Expenses:Food"), "expenses");
557 // Bare root (no colon) still classifies.
558 assert_eq!(account_type("Assets"), "assets");
559 // Non-root / empty → unknown.
560 assert_eq!(account_type("Frobnicate:X"), "unknown");
561 assert_eq!(account_type(""), "unknown");
562 // Case-sensitive, like Beancount.
563 assert_eq!(account_type("assets:bank"), "unknown");
564 }
565
566 #[test]
567 fn test_construction_from_str() {
568 let a = Account::from("Assets:Bank");
569 let c = Currency::from("USD");
570 assert_eq!(a, "Assets:Bank");
571 assert_eq!(c, "USD");
572 }
573
574 #[test]
575 fn test_eq_against_str_in_both_directions() {
576 let a = Account::from("Assets:Bank");
577 assert_eq!(a, "Assets:Bank");
578 assert_eq!("Assets:Bank", a);
579 assert_ne!(a, "Assets:Other");
580 }
581
582 #[test]
583 fn test_eq_against_self_kind() {
584 let a1 = Account::from("Assets:Bank");
585 let a2 = Account::from("Assets:Bank");
586 let a3 = Account::from("Assets:Other");
587 assert_eq!(a1, a2);
588 assert_ne!(a1, a3);
589 }
590
591 #[test]
592 fn test_hash_borrow_str() {
593 use std::collections::HashMap;
594 let mut m: HashMap<Account, u32> = HashMap::new();
595 m.insert(Account::from("Assets:Bank"), 1);
596 // Look up by &str via Borrow<str> impl.
597 assert_eq!(m.get("Assets:Bank"), Some(&1));
598 assert_eq!(m.get("Assets:Other"), None);
599 }
600
601 #[test]
602 fn test_deref_str_methods() {
603 let a = Account::from("Assets:Bank:Checking");
604 assert!(a.starts_with("Assets:"));
605 assert!(a.contains(':'));
606 assert_eq!(a.len(), 20);
607 }
608
609 #[test]
610 fn test_round_trip_interned() {
611 let i = InternedStr::from("USD");
612 let c = Currency::from(i.clone());
613 assert_eq!(c.as_interned(), &i);
614 assert_eq!(c.into_interned(), i);
615 }
616
617 #[test]
618 fn test_different_newtypes_dont_cross() {
619 // This test is structural — uncommenting either of the
620 // assignment lines below MUST cause a compile error
621 // (verified by the doc-comment compile_fail block on the
622 // module). Here we just confirm the runtime types are
623 // distinct via a function signature.
624 fn want_account(_: Account) {}
625 fn want_currency(_: Currency) {}
626 want_account(Account::from("Assets:X"));
627 want_currency(Currency::from("USD"));
628 }
629
630 #[test]
631 fn test_serde_roundtrip() {
632 let a = Account::from("Assets:Bank");
633 let json = serde_json::to_string(&a).unwrap();
634 assert_eq!(json, "\"Assets:Bank\"");
635 let back: Account = serde_json::from_str(&json).unwrap();
636 assert_eq!(a, back);
637 }
638
639 #[test]
640 fn is_subaccount_or_equal_exact_match() {
641 assert!(is_subaccount_or_equal("Assets:Bank", "Assets:Bank"));
642 }
643
644 #[test]
645 fn is_subaccount_or_equal_proper_subaccount() {
646 assert!(is_subaccount_or_equal(
647 "Assets:Bank:Checking",
648 "Assets:Bank"
649 ));
650 assert!(is_subaccount_or_equal(
651 "Assets:Bank:Checking:Joint",
652 "Assets:Bank"
653 ));
654 }
655
656 #[test]
657 fn is_subaccount_or_equal_prefix_without_segment_boundary_excluded() {
658 // The whole point of the segment-boundary rule: a name that
659 // starts with the parent's bytes but isn't followed by `:`
660 // is NOT a sub-account. This is the case the validator and
661 // the LSP both depend on; if the rule ever drifts, balance
662 // assertions for `Assets:Bank` would silently include
663 // `Assets:BankAlias` postings.
664 assert!(!is_subaccount_or_equal("Assets:BankAlias", "Assets:Bank"));
665 assert!(!is_subaccount_or_equal(
666 "Assets:BankAlias:Checking",
667 "Assets:Bank"
668 ));
669 }
670
671 #[test]
672 fn is_subaccount_or_equal_parent_is_prefix_substring_excluded() {
673 // `Assets:Ban` is not a sub-account of `Assets:Bank` —
674 // unrelated except for sharing a prefix.
675 assert!(!is_subaccount_or_equal("Assets:Ban", "Assets:Bank"));
676 }
677
678 #[test]
679 fn is_subaccount_or_equal_empty_inputs() {
680 // Both empty: trivially equal, returns true.
681 assert!(is_subaccount_or_equal("", ""));
682 // Empty parent against a non-empty child: child does not
683 // start with `:`, so excluded. (Beancount account names
684 // never start with `:`; this is a defensive-by-construction
685 // case for callers that pass garbage.)
686 assert!(!is_subaccount_or_equal("Assets:Bank", ""));
687 // Empty child against non-empty parent: child shorter, no
688 // segment boundary possible.
689 assert!(!is_subaccount_or_equal("", "Assets:Bank"));
690 }
691
692 #[test]
693 fn is_subaccount_or_equal_case_sensitive() {
694 // Beancount account names are case-sensitive; the helper
695 // delegates to byte equality and starts_with, both of which
696 // honor case.
697 assert!(!is_subaccount_or_equal("Assets:bank", "Assets:Bank"));
698 assert!(!is_subaccount_or_equal(
699 "assets:Bank:Checking",
700 "Assets:Bank"
701 ));
702 }
703}