rustledger_core/inventory/mod.rs
1//! Inventory type representing a collection of positions.
2//!
3//! An [`Inventory`] tracks the holdings of an account as a collection of
4//! [`Position`]s. It provides methods for adding and reducing positions
5//! using different booking methods (FIFO, LIFO, STRICT, NONE).
6
7// ratchet: fxhash-only — hot path; use FxHashMap/FxHashSet, not std SipHash collections (#1237).
8use imbl::Vector;
9use rust_decimal::Decimal;
10use rustc_hash::FxHashMap;
11use serde::{Deserialize, Serialize};
12use smallvec::SmallVec;
13use std::fmt;
14use std::str::FromStr;
15
16use crate::{Account, Amount, CostSpec, Currency, Position, is_subaccount_or_equal};
17
18/// Inline storage for `BookingResult::matched`.
19///
20/// STRICT booking (the default) always produces exactly one matched lot
21/// per posting; FIFO / LIFO frequently match a single lot too. Inline
22/// cap of 1 covers the hot case with zero heap allocation while still
23/// spilling to the heap for multi-lot matches.
24///
25/// **API surface note**: this is `pub(crate)` deliberately — we don't
26/// want to commit downstream consumers to `smallvec` as part of our
27/// public API contract. External code reads `BookingResult.matched` via
28/// the slice deref (`.iter()`, `.len()`, indexing) which works
29/// transparently. The concrete `SmallVec<[Position; 1]>` type is still
30/// reachable via the field type but isn't promoted into the crate root.
31pub(crate) type MatchedLots = SmallVec<[Position; 1]>;
32
33mod booking;
34
35/// Booking method determines how lots are matched when reducing positions.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
37#[cfg_attr(
38 feature = "rkyv",
39 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
40)]
41pub enum BookingMethod {
42 /// Lots must match exactly (unambiguous).
43 /// If multiple lots match the cost spec, an error is raised.
44 #[default]
45 Strict,
46 /// Like STRICT, but exact-size matches accept oldest lot.
47 /// If reduction amount equals total inventory, it's considered unambiguous.
48 StrictWithSize,
49 /// First In, First Out. Oldest lots are reduced first.
50 Fifo,
51 /// Last In, First Out. Newest lots are reduced first.
52 Lifo,
53 /// Highest In, First Out. Highest-cost lots are reduced first.
54 Hifo,
55 /// Average cost booking. All lots of a currency are merged.
56 Average,
57 /// No cost tracking. Units are reduced without matching lots.
58 None,
59}
60
61impl FromStr for BookingMethod {
62 type Err = String;
63
64 fn from_str(s: &str) -> Result<Self, Self::Err> {
65 match s.to_uppercase().as_str() {
66 "STRICT" => Ok(Self::Strict),
67 "STRICT_WITH_SIZE" => Ok(Self::StrictWithSize),
68 "FIFO" => Ok(Self::Fifo),
69 "LIFO" => Ok(Self::Lifo),
70 "HIFO" => Ok(Self::Hifo),
71 "AVERAGE" => Ok(Self::Average),
72 "NONE" => Ok(Self::None),
73 _ => Err(format!("unknown booking method: {s}")),
74 }
75 }
76}
77
78impl fmt::Display for BookingMethod {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 match self {
81 Self::Strict => write!(f, "STRICT"),
82 Self::StrictWithSize => write!(f, "STRICT_WITH_SIZE"),
83 Self::Fifo => write!(f, "FIFO"),
84 Self::Lifo => write!(f, "LIFO"),
85 Self::Hifo => write!(f, "HIFO"),
86 Self::Average => write!(f, "AVERAGE"),
87 Self::None => write!(f, "NONE"),
88 }
89 }
90}
91
92/// Controls which positions are considered when checking whether incoming
93/// units reduce (i.e. have the opposite sign of) an existing inventory.
94///
95/// - [`AllPositions`](ReductionScope::AllPositions): every position is
96/// considered, regardless of whether it carries a cost.
97/// - [`CostBearingOnly`](ReductionScope::CostBearingOnly): only positions
98/// with a cost are considered. This prevents a negative simple (no-cost)
99/// position — left behind by a sell-without-cost-spec — from causing a
100/// subsequent cost-bearing augmentation to be misclassified as a reduction.
101/// See: issue #875, beancount#889.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103pub enum ReductionScope {
104 /// Consider all positions (cost-bearing and simple).
105 AllPositions,
106 /// Consider only positions that carry a cost.
107 CostBearingOnly,
108}
109
110/// Result of a booking operation.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct BookingResult {
113 /// Positions that were matched/reduced.
114 ///
115 /// Backed by [`SmallVec<[Position; 1]>`](smallvec::SmallVec) so the
116 /// single-match common case (always true under STRICT, common under
117 /// FIFO/LIFO) doesn't touch the heap. The concrete type derefs to
118 /// `[Position]`, so read-side patterns like `.iter()`,
119 /// `.len()`, `.is_empty()`, and indexing work unchanged.
120 ///
121 /// **Breaking API change in 0.15.0**: prior versions used
122 /// `Vec<Position>`. Downstream code that named the type explicitly
123 /// (`let v: Vec<Position> = result.matched`) or called Vec-specific
124 /// methods (`.capacity()`, `.reserve()`) needs to adapt; reading
125 /// the field through the slice deref keeps working.
126 pub matched: MatchedLots,
127 /// The cost basis of the matched positions (for capital gains).
128 pub cost_basis: Option<Amount>,
129}
130
131/// Error that can occur during booking.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum BookingError {
134 /// Multiple lots match but booking method requires unambiguous match.
135 AmbiguousMatch {
136 /// Number of lots that matched.
137 num_matches: usize,
138 /// The currency being reduced.
139 currency: crate::Currency,
140 },
141 /// No lots match the cost specification.
142 NoMatchingLot {
143 /// The currency being reduced.
144 currency: crate::Currency,
145 /// The cost spec that didn't match.
146 cost_spec: CostSpec,
147 },
148 /// Not enough units in matching lots.
149 InsufficientUnits {
150 /// The currency being reduced.
151 currency: crate::Currency,
152 /// Units requested.
153 requested: Decimal,
154 /// Units available.
155 available: Decimal,
156 },
157 /// Currency mismatch between reduction and inventory.
158 CurrencyMismatch {
159 /// Expected currency.
160 expected: crate::Currency,
161 /// Got currency.
162 got: crate::Currency,
163 },
164 /// A `{*}` merge produced a different pool than booking recorded (#2068).
165 ///
166 /// `{*}` is an OPERATION, not a filter: unlike every other cost spec it
167 /// restructures the lots before selecting from them. Booking therefore
168 /// carries the marker into application rather than resolving it into a
169 /// per-unit cost, because the lot it would name does not exist until the
170 /// merge runs.
171 ///
172 /// The consequence is that a booked posting carrying `{*}` re-executes the
173 /// merge when applied, so it is only meaningful against the state it was
174 /// booked against. Booking also records the pool cost it computed, and
175 /// application checks it here — turning "applied against different state,
176 /// silently different answer" into a reported error.
177 MergeMismatch {
178 /// The commodity being reduced (e.g. `AAPL`).
179 currency: crate::Currency,
180 /// The per-unit pool cost booking recorded, in the cost currency.
181 expected: crate::Amount,
182 /// The per-unit pool cost the merge would produce, in the cost currency.
183 ///
184 /// Carried as an [`Amount`] rather than a bare number
185 /// so the message reads `110.00 USD`: the pool cost is denominated in
186 /// the COST currency, which is not the commodity in `currency`.
187 got: crate::Amount,
188 },
189 /// The arithmetic left `rust_decimal`'s ~±7.9e28 range (#1863).
190 ///
191 /// Reported rather than clamped: `Decimal::MIN == -Decimal::MAX`, so
192 /// clamped debits and credits cancel to a residual of exactly zero and an
193 /// arbitrarily unbalanced ledger certifies as clean. Reported rather than
194 /// panicked because ledger input must never abort the CLI.
195 Overflow(OverflowError),
196}
197
198/// A `Decimal` computation whose result cannot be represented.
199///
200/// `rust_decimal` is a 96-bit type with a hard ~±7.9e28 magnitude ceiling and
201/// its `+`/`*` panic on overflow. There is no in-range answer to substitute,
202/// so the arithmetic reports instead of clamping.
203///
204/// Used where an operation MUTATES an inventory, so a caller that reports and
205/// continues needs to know which currency was left alone. Pure leaf arithmetic
206/// returns a plain `Option` instead and lets its caller supply the context:
207/// [`crate::Cost::total_cost`], [`sum_account_and_subaccounts`], and
208/// `rustledger_booking`'s weight ladder. The split is about who is positioned
209/// to write the diagnostic, not about which failures matter.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct OverflowError {
212 /// The currency whose running total left the range.
213 pub currency: crate::Currency,
214}
215
216impl fmt::Display for OverflowError {
217 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218 write!(
219 f,
220 "{} amount exceeds the representable range (±7.9e28); \
221 split the transaction, or denominate it in larger units \
222 (thousands, millions) so the number is smaller",
223 self.currency
224 )
225 }
226}
227
228impl std::error::Error for OverflowError {}
229
230impl From<OverflowError> for BookingError {
231 fn from(e: OverflowError) -> Self {
232 Self::Overflow(e)
233 }
234}
235
236impl fmt::Display for BookingError {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 match self {
239 Self::MergeMismatch {
240 currency,
241 expected,
242 got,
243 } => write!(
244 f,
245 "{{*}} merge of {currency} would produce a pool cost of {got}, \
246 but booking recorded {expected}: this posting is being applied \
247 against different inventory than it was booked against"
248 ),
249 Self::AmbiguousMatch {
250 num_matches,
251 currency,
252 } => write!(
253 f,
254 "Ambiguous match: {num_matches} lots match for {currency}"
255 ),
256 Self::NoMatchingLot {
257 currency,
258 cost_spec,
259 } => {
260 write!(f, "No matching lot for {currency} with cost {cost_spec}")
261 }
262 Self::InsufficientUnits {
263 currency,
264 requested,
265 available,
266 } => write!(
267 f,
268 "Insufficient units of {currency}: requested {requested}, available {available}"
269 ),
270 Self::CurrencyMismatch { expected, got } => {
271 write!(f, "Currency mismatch: expected {expected}, got {got}")
272 }
273 Self::Overflow(e) => write!(f, "{e}"),
274 }
275 }
276}
277
278impl std::error::Error for BookingError {}
279
280impl BookingError {
281 /// Wrap this booking error with the account context that produced it.
282 ///
283 /// `Inventory` itself doesn't know which account it belongs to, so the
284 /// raw `BookingError` carries no `account` field. The caller (booking
285 /// engine, validator) knows the account and uses this constructor to
286 /// produce the user-facing error.
287 ///
288 /// The resulting [`AccountedBookingError`] is the **single canonical
289 /// rendering** of an inventory failure for user-facing output. Both the
290 /// booking layer and the validator format errors via this type so the
291 /// wording cannot drift between them — the failure mode that produced
292 /// #748.
293 #[must_use]
294 pub const fn with_account(self, account: crate::Account) -> AccountedBookingError {
295 AccountedBookingError {
296 error: self,
297 account,
298 }
299 }
300}
301
302/// A [`BookingError`] paired with the account that produced it.
303///
304/// This is the canonical user-facing inventory error type. Its `Display`
305/// impl is the **single source of truth** for booking-error wording across
306/// `rustledger-booking` and `rustledger-validate`. Conformance assertions
307/// (e.g. pta-standards `reduction-exceeds-inventory` requires the literal
308/// substring `"not enough"`) are pinned by this Display.
309///
310/// Construct via [`BookingError::with_account`].
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub struct AccountedBookingError {
313 /// The underlying inventory-level error.
314 pub error: BookingError,
315 /// The account whose inventory produced the error.
316 pub account: crate::Account,
317}
318
319impl fmt::Display for AccountedBookingError {
320 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321 match &self.error {
322 // The currency is already named in the inner message; the account
323 // is the context this wrapper exists to add.
324 BookingError::Overflow(e) => write!(f, "{}: {e}", self.account),
325 BookingError::MergeMismatch { .. } => write!(f, "{}: {}", self.account, self.error),
326 BookingError::InsufficientUnits {
327 requested,
328 available,
329 ..
330 } => write!(
331 f,
332 "Not enough units in {}: requested {}, available {}; not enough to reduce",
333 self.account, requested, available
334 ),
335 BookingError::NoMatchingLot { currency, .. } => {
336 write!(f, "No matching lot for {} in {}", currency, self.account)
337 }
338 BookingError::AmbiguousMatch {
339 num_matches,
340 currency,
341 } => write!(
342 f,
343 "Ambiguous lot match for {}: {} lots match in {}",
344 currency, num_matches, self.account
345 ),
346 // Currency mismatch is semantically a specialization of
347 // NoMatchingLot (there is no lot for the given currency in this
348 // inventory), so we render and classify it the same way. Consumers
349 // filtering on E4001 don't need to special-case CurrencyMismatch.
350 //
351 // This variant is defensive: no `Inventory::reduce` path in
352 // `rustledger-core` currently emits it, but we still render it
353 // consistently in case a future emission site is added.
354 BookingError::CurrencyMismatch { got, .. } => {
355 write!(f, "No matching lot for {} in {}", got, self.account)
356 }
357 }
358 }
359}
360
361impl std::error::Error for AccountedBookingError {}
362/// How an [`Inventory`] holds its positions.
363///
364/// The two backings exist because the two uses want opposite things, and a
365/// single choice was measurably wrong for one of them:
366///
367/// * **Booking** mutates an inventory constantly — `add`, and a `reduce` that
368/// filters, sorts and then indexes matched lots — and snapshots it only on
369/// the conditional overflow-rollback path. It wants contiguous storage:
370/// O(1) indexing and cache-friendly iteration.
371/// * **BQL's JOURNAL running balance** is only appended to, and is CLONED
372/// once per output row. It wants structural sharing: N snapshots costing
373/// O(base + sum of deltas) rather than O(N x base) (#1086 — measured at
374/// 32.7 MB peak RSS for 2000 lots x 2000 rows; a contiguous clone per row
375/// holds ~2M positions instead).
376///
377/// Holding everything in the persistent vector made every reduction pay RRB
378/// costs: on a lot-heavy workload `imbl::Vector`'s iterator alone was ~13% of
379/// all instructions, and indexed access inside `reduce_ordered` is O(log M)
380/// per lookup rather than O(1). Holding everything contiguously reintroduces
381/// the #1086 blow-up. So the representation follows the use.
382#[derive(Debug, Clone)]
383enum PositionStore {
384 /// Contiguous — booking's working representation.
385 ///
386 /// SPARSE: a slot holding `None` is a lot a reduction drained and removed.
387 /// Removing by shifting renumbers every later lot, and lot indices have to
388 /// survive removals for a cost-keyed match index to be possible at all.
389 /// Tombstoning makes removal O(1) and leaves every other slot untouched.
390 ///
391 /// `None` is NOT the same as a zero-unit position. A zero-unit lot is live
392 /// and visible — cost-less lots can merge through zero — and
393 /// [`Inventory::len`] is documented as counting them. A tombstone is a lot
394 /// that is GONE. Encoding one as the other would make drained lots visible
395 /// to `Serialize`, the FFI and wasm converters, the account validator and
396 /// `report balances`, and would change what `currency_accounts` sees when
397 /// it branches on `inv.len() == 1` to match Python.
398 Owned(Slots),
399 /// Structurally shared — BQL's snapshot representation, and dense: BQL
400 /// clones snapshots but never books against them, so it has no removals to
401 /// keep indices stable across.
402 Shared(Vector<Position>),
403}
404
405/// Iterator over [`PositionStore`], as a stack-allocated enum.
406///
407/// Deliberately NOT `Box<dyn Iterator>`: `iter` is called from `units`,
408/// `merge`, `at_cost`, equality and every reduction pass, so boxing would put
409/// a heap allocation and a dynamic dispatch on paths this change exists to
410/// make cheaper. Copilot's catch on #2056.
411enum PositionStoreIter<'a> {
412 Owned(std::iter::Flatten<std::slice::Iter<'a, Option<Position>>>),
413 Shared(imbl::vector::Iter<'a, Position, imbl::shared_ptr::DefaultSharedPtr>),
414}
415
416impl<'a> Iterator for PositionStoreIter<'a> {
417 type Item = &'a Position;
418
419 fn next(&mut self) -> Option<Self::Item> {
420 match self {
421 Self::Owned(i) => i.next(),
422 Self::Shared(i) => i.next(),
423 }
424 }
425
426 fn size_hint(&self) -> (usize, Option<usize>) {
427 match self {
428 Self::Owned(i) => i.size_hint(),
429 Self::Shared(i) => i.size_hint(),
430 }
431 }
432}
433
434/// The sparse backing: slots plus the number that are live.
435///
436/// `live` is maintained rather than counted because `len`, `is_empty` and the
437/// compaction trigger all run per reduction, and an O(slots) scan there is
438/// exactly the cost tombstones exist to avoid.
439#[derive(Debug, Clone, Default)]
440struct Slots {
441 entries: Vec<Option<Position>>,
442 live: usize,
443 /// Prior contents of every slot this transaction has touched, so a failed
444 /// transaction can be undone without having copied the whole account.
445 ///
446 /// `None` while not recording. Recorded lazily and ONCE per slot — the
447 /// first write captures what to restore; later writes to the same slot are
448 /// already covered.
449 ///
450 /// Recording lives here, in the backing, rather than at the eleven call
451 /// sites that mutate positions. Those all funnel through this type's
452 /// primitives and the field is private, so covering the primitives is
453 /// complete by construction; covering call sites would be complete only
454 /// until someone adds a twelfth.
455 undo: Option<Vec<(usize, Option<Position>)>>,
456 /// Slots already captured in `undo`, for O(1) "have I recorded this?".
457 ///
458 /// This bounds the log's size and cost; it is not what makes rollback
459 /// correct. Restoring in reverse order already makes a duplicate entry
460 /// harmless, because the earliest capture is applied last. Removing this
461 /// therefore does not fail any test — it just lets the log grow.
462 ///
463 /// A linear scan of the log reads fine for a transaction touching one or
464 /// two slots, but `{*}` merge records every matched lot, which made
465 /// recording O(k^2) in the lots merged — a fresh quadratic inside the
466 /// change that removed one. Hashing a `usize` with `FxHash` is a couple of
467 /// instructions, so the common case loses nothing.
468 undo_seen: rustc_hash::FxHashSet<usize>,
469}
470
471impl Slots {
472 fn from_live(positions: Vec<Position>) -> Self {
473 let live = positions.len();
474 Self {
475 entries: positions.into_iter().map(Some).collect(),
476 live,
477 undo: None,
478 undo_seen: rustc_hash::FxHashSet::default(),
479 }
480 }
481
482 /// Capture slot `i`'s current contents, if recording and not already held.
483 ///
484 /// The "already held" check is a linear scan. A transaction touches one or
485 /// two slots, where a scan beats hashing; the worst case is a `{*}` merge,
486 /// which records every matched lot and makes this O(k^2) in the lots
487 /// merged. That is bounded by one transaction and by an operation that is
488 /// rare, which is why it is not a set.
489 fn record(&mut self, i: usize) {
490 if self.undo.is_none() {
491 return;
492 }
493 if !self.undo_seen.insert(i) {
494 return;
495 }
496 let prior = self.entries.get(i).cloned().flatten();
497 if let Some(log) = self.undo.as_mut() {
498 log.push((i, prior));
499 }
500 }
501
502 /// Tombstone slot `i`, keeping `live` in step.
503 ///
504 /// Out of range is a caller bug, not a condition: slot indices come from
505 /// `iter_slots` or `push_slot` and are internal throughout. It cannot
506 /// panic in release — silently skipping is better than aborting on a
507 /// ledger — but it must not pass unnoticed in a test run, or a desynced
508 /// `live` count shows up later as a wrong compaction trigger with nothing
509 /// pointing back here.
510 fn set_dead(&mut self, i: usize) {
511 self.record(i);
512 debug_assert!(
513 i < self.entries.len(),
514 "set_dead called with out-of-range slot {i} (of {})",
515 self.entries.len(),
516 );
517 if let Some(entry) = self.entries.get_mut(i)
518 && entry.take().is_some()
519 {
520 self.live -= 1;
521 }
522 }
523}
524
525/// Iterator over [`PositionStore`] yielding each live position with its SLOT
526/// index — the index [`std::ops::Index`] accepts, not a running count.
527///
528/// A stack-allocated enum for the same reason as [`PositionStoreIter`]: this
529/// runs inside every reduction and must not box.
530enum SlotIter<'a> {
531 Owned(std::iter::Enumerate<std::slice::Iter<'a, Option<Position>>>),
532 Shared(
533 std::iter::Enumerate<imbl::vector::Iter<'a, Position, imbl::shared_ptr::DefaultSharedPtr>>,
534 ),
535}
536
537impl<'a> SlotIter<'a> {
538 fn new(store: &'a PositionStore) -> Self {
539 match store {
540 PositionStore::Owned(v) => Self::Owned(v.entries.iter().enumerate()),
541 PositionStore::Shared(v) => Self::Shared(v.iter().enumerate()),
542 }
543 }
544}
545
546impl<'a> Iterator for SlotIter<'a> {
547 type Item = (usize, &'a Position);
548
549 fn next(&mut self) -> Option<Self::Item> {
550 match self {
551 Self::Owned(i) => {
552 // Skip tombstones, but keep the REAL slot number of what we do
553 // yield: that number is what comes back through `Index`.
554 for (slot, entry) in i.by_ref() {
555 if let Some(position) = entry {
556 return Some((slot, position));
557 }
558 }
559 None
560 }
561 Self::Shared(i) => i.next(),
562 }
563 }
564}
565
566impl Default for PositionStore {
567 fn default() -> Self {
568 Self::Owned(Slots::default())
569 }
570}
571
572impl PositionStore {
573 /// Every live position paired with the index that [`Index`] will accept
574 /// for it.
575 ///
576 /// Today this is exactly `iter().enumerate()`, because every element of
577 /// the backing store is live. It exists as its own method because that
578 /// equivalence is a PROPERTY OF THE CURRENT STORAGE, not a law: the
579 /// reduction paths collect indices here and hand them back through
580 /// `Index`/`IndexMut`, so anything that makes the backing sparse — the
581 /// tombstoned lots that would let a cost-keyed index survive removals —
582 /// silently desynchronises the two unless every such site goes through
583 /// one place. This is that place.
584 ///
585 /// [`Index`]: std::ops::Index
586 fn iter_slots(&self) -> impl Iterator<Item = (usize, &Position)> {
587 // Real slot numbers, skipping tombstones — NOT a running count of live
588 // positions. The reduction paths hand these back to `Index`.
589 SlotIter::new(self)
590 }
591
592 /// Live positions, dropping tombstones. This is what `Serialize`, the FFI
593 /// converters and every external consumer see, so a drained lot stays
594 /// invisible exactly as it was when removal shifted the vector.
595 fn iter(&self) -> PositionStoreIter<'_> {
596 match self {
597 Self::Owned(v) => PositionStoreIter::Owned(v.entries.iter().flatten()),
598 Self::Shared(v) => PositionStoreIter::Shared(v.iter()),
599 }
600 }
601
602 /// Number of LIVE positions. Tombstones are not positions.
603 fn len(&self) -> usize {
604 match self {
605 Self::Owned(v) => v.live,
606 Self::Shared(v) => v.len(),
607 }
608 }
609
610 /// Number of slots, live or not — the exclusive upper bound on a valid
611 /// slot index, and what `push_slot` returns for the slot it fills.
612 fn slot_count(&self) -> usize {
613 match self {
614 Self::Owned(v) => v.entries.len(),
615 Self::Shared(v) => v.len(),
616 }
617 }
618
619 /// How many slots are tombstones.
620 const fn dead(&self) -> usize {
621 match self {
622 Self::Owned(v) => v.entries.len() - v.live,
623 Self::Shared(_) => 0,
624 }
625 }
626
627 fn is_empty(&self) -> bool {
628 self.len() == 0
629 }
630
631 fn get(&self, i: usize) -> Option<&Position> {
632 match self {
633 Self::Owned(v) => v.entries.get(i).and_then(Option::as_ref),
634 Self::Shared(v) => v.get(i),
635 }
636 }
637
638 fn push(&mut self, p: Position) {
639 self.push_slot(p);
640 }
641
642 /// Append a position, returning the slot index it landed in.
643 ///
644 /// The slot is `slot_count()`, not `len()`: with tombstones present those
645 /// differ, and `simple_index` stores slots.
646 fn push_slot(&mut self, p: Position) -> usize {
647 let slot = self.slot_count();
648 match self {
649 Self::Owned(v) => {
650 if let Some(log) = v.undo.as_mut() {
651 log.push((slot, None));
652 v.undo_seen.insert(slot);
653 }
654 v.entries.push(Some(p));
655 v.live += 1;
656 }
657 Self::Shared(v) => v.push_back(p),
658 }
659 slot
660 }
661
662 /// Remove the position in slot `i`, leaving a tombstone behind so no other
663 /// slot is renumbered.
664 fn remove(&mut self, i: usize) {
665 match self {
666 Self::Owned(v) => v.set_dead(i),
667 Self::Shared(v) => {
668 v.remove(i);
669 }
670 }
671 }
672
673 /// Drop positions failing `f`. On the sparse backing they become
674 /// tombstones, so no surviving lot is renumbered.
675 fn retain(&mut self, mut f: impl FnMut(&Position) -> bool) {
676 match self {
677 Self::Owned(v) => {
678 for i in 0..v.entries.len() {
679 if v.entries[i].as_ref().is_some_and(|p| !f(p)) {
680 v.set_dead(i);
681 }
682 }
683 }
684 Self::Shared(v) => v.retain(f),
685 }
686 }
687
688 /// Start recording an undo log, discarding any previous one.
689 fn begin_undo(&mut self) {
690 if let Self::Owned(v) = self {
691 v.undo = Some(Vec::new());
692 v.undo_seen.clear();
693 }
694 }
695
696 /// Stop recording and discard the log — the transaction committed.
697 fn commit_undo(&mut self) {
698 if let Self::Owned(v) = self {
699 v.undo = None;
700 v.undo_seen.clear();
701 }
702 }
703
704 /// Restore every slot the log captured, newest first, and stop recording.
705 ///
706 /// Newest first, for two reasons that are easy to conflate.
707 ///
708 /// The visible one: slots CREATED by this transaction are popped from the
709 /// end while they are still last, so failed transactions do not leave dead
710 /// slots behind. Forward order would merely leave tombstones that
711 /// compaction reclaims, so a mutation swapping the order survives the
712 /// suite — expected, not a coverage gap.
713 ///
714 /// The load-bearing one: reverse order is what makes a slot recorded MORE
715 /// THAN ONCE safe. The earliest capture holds the true prior value, and
716 /// reverse iteration applies it last, so it wins. `record` deduplicates, so
717 /// duplicates should not arise — but the two mechanisms are independent,
718 /// and dropping BOTH is what would corrupt a rollback. Changing this to
719 /// forward order is only safe while the deduplication holds.
720 fn rollback_undo(&mut self) {
721 let Self::Owned(v) = self else {
722 return;
723 };
724 let Some(log) = v.undo.take() else {
725 return;
726 };
727 v.undo_seen.clear();
728 for (slot, prior) in log.into_iter().rev() {
729 // The slot existed: put back exactly what was there. Otherwise it
730 // was created by this transaction (or was already a tombstone), so
731 // it must end up not-live — dropped entirely when it is the last
732 // one, so slot numbers do not drift upward across failed
733 // transactions.
734 if let Some(position) = prior {
735 if v.entries[slot].is_none() {
736 v.live += 1;
737 }
738 v.entries[slot] = Some(position);
739 } else {
740 if v.entries[slot].is_some() {
741 v.live -= 1;
742 }
743 v.entries[slot] = None;
744 if slot + 1 == v.entries.len() {
745 v.entries.pop();
746 }
747 }
748 }
749 }
750
751 /// Physically drop tombstones, renumbering the slots that remain.
752 ///
753 /// INVALIDATES every slot index, so callers must hold none and must
754 /// rebuild anything keyed by slot. Reductions never span this: it runs
755 /// before planning, when nothing is held.
756 fn compact_slots(&mut self) {
757 if let Self::Owned(v) = self {
758 v.entries.retain(Option::is_some);
759 debug_assert_eq!(
760 v.entries.len(),
761 v.live,
762 "compaction must leave only live slots"
763 );
764 }
765 }
766
767 /// [`Self::retain`], with each position's slot index — the same index
768 /// [`Self::iter_slots`] reports and [`Index`] accepts.
769 ///
770 /// `reduce_merge` needs this: it selects lots through `iter_slots` and
771 /// then drops exactly those. Written with a plain `retain` and a counter
772 /// incremented per visit, that is only correct while every element the
773 /// store holds is a live position visited in order — the same dense-store
774 /// assumption `iter_slots` exists to keep in one place, arriving by a
775 /// second route that `iter_slots` cannot cover. A sparse store whose
776 /// `retain` skips dead slots would leave the counter numbering live
777 /// positions while the selection numbered real slots, and `{*}` merges
778 /// would delete the wrong lots.
779 ///
780 /// [`Index`]: std::ops::Index
781 fn retain_slots(&mut self, mut f: impl FnMut(usize, &Position) -> bool) {
782 match self {
783 Self::Owned(v) => {
784 for i in 0..v.entries.len() {
785 if v.entries[i].as_ref().is_some_and(|p| !f(i, p)) {
786 v.set_dead(i);
787 }
788 }
789 }
790 // Dense: a running count IS the slot number here.
791 Self::Shared(v) => {
792 let mut slot = 0;
793 v.retain(|position| {
794 let keep = f(slot, position);
795 slot += 1;
796 keep
797 });
798 }
799 }
800 }
801
802 /// Switch to contiguous storage, cloning if not already `Owned`.
803 ///
804 /// `reduce` calls this, which ALSO discharges the uniqueness requirement
805 /// the old unconditional `self.positions.iter().cloned().collect()`
806 /// existed for: mutating a structurally-SHARED `imbl::Vector` in place
807 /// drives `imbl-sized-chunks`' copy-on-write into a use-after-free of the
808 /// interned `Arc<str>` inside `Position`. Materializing into a fresh
809 /// `Vec` leaves nothing shared to corrupt, at the same O(M) cost that
810 /// copy already paid — and every subsequent access in the reduction is
811 /// then contiguous instead of an RRB walk.
812 fn make_owned(&mut self) {
813 if let Self::Shared(v) = self {
814 let slots: Vec<Option<Position>> = v.iter().cloned().map(Some).collect();
815 let live = slots.len();
816 *self = Self::Owned(Slots {
817 entries: slots,
818 live,
819 undo: None,
820 undo_seen: rustc_hash::FxHashSet::default(),
821 });
822 }
823 }
824}
825
826impl std::ops::Index<usize> for PositionStore {
827 type Output = Position;
828 /// # Panics
829 ///
830 /// Panics if `i` names a tombstone. Every index in circulation comes from
831 /// [`PositionStore::iter_slots`] or [`PositionStore::push_slot`], which
832 /// only ever report live slots, and no reduction removes a lot and then
833 /// re-reads it — so reaching a tombstone means slot numbers and the store
834 /// have desynchronised, and a wrong-lot read is worse than a panic.
835 fn index(&self, i: usize) -> &Position {
836 match self {
837 Self::Owned(v) => v.entries[i]
838 .as_ref()
839 .expect("slot index names a live position, not a tombstone"),
840 Self::Shared(v) => &v[i],
841 }
842 }
843}
844
845impl std::ops::IndexMut<usize> for PositionStore {
846 /// # Panics
847 ///
848 /// As [`Index::index`](std::ops::Index::index).
849 fn index_mut(&mut self, i: usize) -> &mut Position {
850 match self {
851 Self::Owned(v) => {
852 // Capture BEFORE the caller writes through the returned
853 // reference — afterwards the prior value is gone.
854 v.record(i);
855 v.entries[i]
856 .as_mut()
857 .expect("slot index names a live position, not a tombstone")
858 }
859 Self::Shared(v) => &mut v[i],
860 }
861 }
862}
863
864impl FromIterator<Position> for PositionStore {
865 fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
866 let slots: Vec<Option<Position>> = iter.into_iter().map(Some).collect();
867 let live = slots.len();
868 Self::Owned(Slots {
869 entries: slots,
870 live,
871 undo: None,
872 undo_seen: rustc_hash::FxHashSet::default(),
873 })
874 }
875}
876
877// Serialized as a plain sequence, identical for both backings — the wire
878// format does not encode which representation happens to be in use, and a
879// round-trip always lands in `Owned` (deserialization is followed by
880// `rebuild_index`, and a freshly-loaded inventory is about to be mutated far
881// more often than snapshotted).
882impl Serialize for PositionStore {
883 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
884 serializer.collect_seq(self.iter())
885 }
886}
887
888impl<'de> Deserialize<'de> for PositionStore {
889 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
890 Ok(Self::Owned(Slots::from_live(Vec::<Position>::deserialize(
891 deserializer,
892 )?)))
893 }
894}
895
896/// An inventory is a collection of positions.
897///
898/// It tracks all positions for an account and supports booking operations
899/// for adding and reducing positions.
900///
901/// # Examples
902///
903/// ```
904/// use rustledger_core::{Inventory, Position, Amount, Cost, BookingMethod};
905/// use rust_decimal_macros::dec;
906///
907/// let mut inv = Inventory::new();
908///
909/// // Add a simple position
910/// inv.add(Position::simple(Amount::new(dec!(100), "USD")));
911/// assert_eq!(inv.units("USD"), dec!(100));
912///
913/// // Add a position with cost
914/// let cost = Cost::new(dec!(150.00), "USD");
915/// inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
916/// assert_eq!(inv.units("AAPL"), dec!(10));
917/// ```
918#[derive(Debug, Clone, Default, Serialize, Deserialize)]
919// Deserialization goes through `InventoryWire` so the derived caches are
920// REBUILT rather than left empty.
921//
922// `simple_index` and `units_cache` are `#[serde(skip)]`, so a plain derive
923// produced an inventory holding positions with both caches empty. `units()`
924// recomputes on a miss and `add_headroom_for` refuses to answer, but `add()`
925// trusted them: `units_cache.get(..).unwrap_or_default()` read 0 for an
926// inventory already holding 100 USD, then wrote that back as the new total,
927// while the empty `simple_index` meant a cost-less lot was appended instead of
928// merged. A round-tripped 100 USD inventory answered `units("USD") == 5` after
929// adding 5, with two lots where there should be one.
930//
931// `rebuild_index`'s own doc already claimed it ran "after ... deserialization".
932// It did not — nothing called it on that path, and two comments elsewhere
933// referred to it by a name (`rebuild_caches`) that never existed. Now it does.
934#[serde(try_from = "InventoryWire")]
935pub struct Inventory {
936 /// Positions, in whichever backing suits this inventory's use — see
937 /// [`PositionStore`]. Contiguous (`Owned`) by default, which is what
938 /// booking wants; structurally shared (`Shared`) for the BQL running
939 /// balances that are cloned once per output row.
940 ///
941 /// The notes below describe the SHARED backing, and are why it still
942 /// exists:
943 /// This is the critical property for JOURNAL-style row-per-snapshot
944 /// patterns in BQL (issue #1086): N nested snapshots cost O(base + Σ
945 /// deltas) memory instead of O(N · base), and the per-row clone cost
946 /// drops from O(positions) to O(1).
947 ///
948 /// The trade is real: booking and BQL aggregator mutations pay an
949 /// O(log N) tree walk vs `Vec`'s amortized O(1) push. Measured impact
950 /// scales with inventory size M: +85 ns/op at M=10, +1.6 µs/op at
951 /// M=100, +19 µs/op at M=500 (criterion `reduce_fifo/*`). For typical
952 /// small-M ledgers the overhead is sub-millisecond per `rledger
953 /// check`; the users who feel it are users with very large inventories,
954 /// the same users who hit the JOURNAL OOM today.
955 ///
956 /// `rkyv` derives were dropped because (a) `imbl::Vector` has no `rkyv`
957 /// impl and (b) no code path currently archives an `Inventory`
958 /// (confirmed in the `SmallVec` experiment for #1069). Pre-1.0 break;
959 /// downstream callers archiving `Inventory` directly will need to
960 /// archive `Vec<Position>` themselves. Serde wire format is unchanged
961 /// (sequence-typed, identical for both backings).
962 positions: PositionStore,
963 /// Index for O(1) lookup of simple positions (no cost) by currency.
964 /// Maps currency to position index in the `positions` vector.
965 /// Cache of total units per currency for O(1) `units()` lookups.
966 /// Updated incrementally on `add()` and `reduce()`.
967 /// Not serialized - rebuilt on demand.
968 #[serde(skip)]
969 units_cache: FxHashMap<crate::Currency, CurrencyStats>,
970 /// Cost-bearing lots grouped by what a cost spec matches them on, so a
971 /// reduction naming an explicit per-unit cost finds its candidates instead
972 /// of comparing against every lot.
973 ///
974 /// That comparison was the last superlinear term in the pipeline:
975 /// `CostSpec::matches` ran once per lot per reduction and grew 111x for
976 /// 10x the input. Slots are stable across removals — that is what the
977 /// tombstoned backing buys — so the lists stay valid until compaction,
978 /// which rebuilds them.
979 ///
980 /// Only lots WITH a cost appear. A spec that names no per-unit cost still
981 /// scans, because it can match anything.
982 /// Not serialized - rebuilt on demand, like the caches above.
983 #[serde(skip)]
984 cost_index: FxHashMap<CostKey, smallvec::SmallVec<[usize; 2]>>,
985 /// EVERY lot per units-currency, in the order FIFO consumes them: lot date
986 /// ascending, ties broken by slot ascending.
987 ///
988 /// Cost-LESS lots are in here too, and deliberately. An empty cost spec
989 /// matches one (`matches_cost_spec`: `(None, true) => true`), so ordered
990 /// selection can drain one — an index holding only cost-bearing lots chose
991 /// a different lot than the scan it replaced, which is what the
992 /// scan-equivalence test caught. `cost_index` is the map keyed on cost;
993 /// this one is keyed on nothing but the commodity.
994 ///
995 /// `cost_index` cannot serve an under-specified spec — a bare `{}` names
996 /// no cost to key on — so ordered selection scanned every slot instead,
997 /// once per reduction. Walking this list stops as soon as the reduction is
998 /// covered, so the common single-lot sale touches one entry (#2083).
999 ///
1000 /// Same safety asymmetry as `cost_index`: a STALE entry is harmless
1001 /// because the walk re-checks liveness, sign and the spec, while a MISSING
1002 /// entry hides a lot the reduction should have seen. Insertion is at the
1003 /// single `add` site; removal rides on `cost_index_remove`.
1004 /// Not serialized - rebuilt on demand, like the caches above.
1005 /// `None` until ordered selection asks for it: boxed so an inventory that
1006 /// never needs one carries a pointer rather than a map. The map itself is
1007 /// three words plus its allocation, on a type that is created per account
1008 /// and cloned per BQL output row.
1009 #[serde(skip)]
1010 ordered_index: Option<Box<OrderedIndex>>,
1011 /// Whether an undo log is open. Not serialized; a transaction never spans
1012 /// a round trip.
1013 #[serde(skip)]
1014 undo_open: bool,
1015 /// Debug-only copy taken at `begin_undo`, compared against the restored
1016 /// inventory to prove the log covered every mutation.
1017 #[cfg(debug_assertions)]
1018 #[serde(skip)]
1019 undo_witness: Option<Box<Self>>,
1020}
1021
1022/// The order a booking method consumes lots in.
1023///
1024/// One inventory needs one ordering, because an account has one booking
1025/// method — so this is stored alongside the index rather than a second index
1026/// being kept in step with the first.
1027#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1028pub(super) enum LotOrder {
1029 /// Oldest lot first. FIFO walks it forward, LIFO backward.
1030 Date,
1031 /// Most expensive lot first, which is what HIFO takes.
1032 CostDescending,
1033}
1034
1035/// Lots per units-currency, in the order some booking method consumes them.
1036#[derive(Debug, Clone)]
1037pub(super) struct OrderedIndex {
1038 /// Which ordering `by_currency` is sorted in.
1039 order: LotOrder,
1040 /// Slots per units-currency, sorted by `order` then slot ascending.
1041 by_currency: FxHashMap<crate::Currency, Vec<usize>>,
1042}
1043
1044/// What [`Inventory::cost_index`] groups lots by: the units they are held in,
1045/// and the per-unit cost a spec would name to select them.
1046type CostKey = (crate::Currency, Decimal, crate::Currency);
1047
1048/// The key for `position`, if it carries a cost.
1049fn cost_key(position: &Position) -> Option<CostKey> {
1050 position.cost.as_ref().map(|cost| {
1051 (
1052 position.units.currency.clone(),
1053 cost.number,
1054 cost.currency.clone(),
1055 )
1056 })
1057}
1058
1059/// Everything cached per currency: the running unit total, and the per-bucket
1060/// position counts that make [`Inventory::is_reduced_by`] O(1).
1061///
1062/// Deliberately ONE map rather than two. `add` already did a `get` plus an
1063/// `insert` on the units cache, and hanging a second map off the same key
1064/// added a third hash of an interned string per posting — which measured as a
1065/// 4% regression on the cost-spec-free `simple` profiling shape, wiping out
1066/// part of what the index bought on `investment`. Folded in here, the counts
1067/// ride along on a lookup that was already happening.
1068#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1069struct CurrencyStats {
1070 /// Running total of units across every lot of this currency.
1071 total: Decimal,
1072 /// Position counts by (sign, cost-bearing) bucket.
1073 counts: SignCounts,
1074 /// Slot of the single cost-less lot of this currency, if there is one —
1075 /// the lot a later cost-less `add` merges into.
1076 ///
1077 /// Folded in here rather than kept in its own map because every reader
1078 /// wants it alongside the totals: `add` looked the currency up once for
1079 /// the total and again for this, and `add_headroom_for` did the same, so
1080 /// each posting hashed the same interned string twice for no reason. The
1081 /// two have identical lifetimes — neither is ever pruned, both are
1082 /// `#[serde(skip)]` and both are rebuilt together by `rebuild_caches` —
1083 /// so there was never a state one could describe and the other could not.
1084 simple_slot: Option<usize>,
1085}
1086
1087/// How many positions of a currency fall in each (sign, cost-bearing) bucket.
1088///
1089/// Buckets keyed on `Decimal::is_sign_positive`, which is the exact predicate
1090/// [`Inventory::is_reduced_by`] uses — note it answers `true` for zero, and
1091/// the scan it replaces counted empty positions too, so this must as well.
1092#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1093struct SignCounts {
1094 /// Cost-bearing lots whose units are sign-positive.
1095 cost_positive: u32,
1096 /// Cost-bearing lots whose units are sign-negative.
1097 cost_negative: u32,
1098 /// Cost-less lots whose units are sign-positive.
1099 simple_positive: u32,
1100 /// Cost-less lots whose units are sign-negative.
1101 simple_negative: u32,
1102}
1103
1104impl SignCounts {
1105 /// Count of positions in the bucket opposite to `units_is_positive`.
1106 const fn opposite(self, units_is_positive: bool, scope: ReductionScope) -> u32 {
1107 let (cost, simple) = if units_is_positive {
1108 (self.cost_negative, self.simple_negative)
1109 } else {
1110 (self.cost_positive, self.simple_positive)
1111 };
1112 match scope {
1113 // Saturating, not `+`: these are counts of lots and a wrap would
1114 // read as "no matching lot", which books a reduction as an
1115 // augmentation and duplicates the lot. Saturation errs the other
1116 // way, and `> 0` is all the caller asks.
1117 ReductionScope::AllPositions => cost.saturating_add(simple),
1118 ReductionScope::CostBearingOnly => cost,
1119 }
1120 }
1121
1122 /// `delta` is `i32` rather than `i64` so it feeds `saturating_add_signed`
1123 /// directly. The earlier `i64` version ended in `try_into().unwrap_or(0)`,
1124 /// which turns a caller mistake into a silent no-op — the one outcome that
1125 /// leaves the counts wrong with nothing to show for it.
1126 fn bump(&mut self, has_cost: bool, is_positive: bool, delta: i32) {
1127 debug_assert!(
1128 delta == 1 || delta == -1,
1129 "counts move one lot at a time; {delta} means a caller lost track",
1130 );
1131 let slot = match (has_cost, is_positive) {
1132 (true, true) => &mut self.cost_positive,
1133 (true, false) => &mut self.cost_negative,
1134 (false, true) => &mut self.simple_positive,
1135 (false, false) => &mut self.simple_negative,
1136 };
1137 // Saturating: an under-count can only make `is_reduced_by` answer
1138 // "not a reduction" and fall back to augmentation, where a wrapped
1139 // u32 would claim billions of matching lots.
1140 *slot = slot.saturating_add_signed(delta);
1141 }
1142}
1143
1144/// Where the positions a cache rebuild is reading came from.
1145///
1146/// Only affects whether the one-cost-less-lot-per-currency invariant is
1147/// ASSERTED. It is a genuine invariant of positions this type built, and a
1148/// `debug_assert` there earns its keep as an internal-bug tripwire — but a
1149/// deserialized payload is input, and input must not be able to panic us.
1150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1151enum CacheSource {
1152 /// Positions this inventory produced; the invariant holds.
1153 Internal,
1154 /// Positions from outside — a deserialized payload.
1155 Untrusted,
1156}
1157
1158/// Deserialization shape for [`Inventory`]: the persisted field only.
1159///
1160/// Exists so `From` can rebuild the derived caches — see the note on
1161/// `Inventory`. Kept private; the wire format is unchanged (a struct with a
1162/// `positions` sequence), so this is not a compatibility break.
1163#[derive(Deserialize)]
1164struct InventoryWire {
1165 // NOT `#[serde(default)]`. The derive this replaces made `positions`
1166 // required, so `{}` was `Err("missing field `positions`")`; defaulting it
1167 // would quietly accept a malformed payload as an empty inventory.
1168 positions: Vector<Position>,
1169}
1170
1171impl TryFrom<InventoryWire> for Inventory {
1172 type Error = OverflowError;
1173
1174 /// `TryFrom`, not `From`: rebuilding the caches sums a currency's positions,
1175 /// and that sum can overflow on a payload nobody sane wrote.
1176 ///
1177 /// `rebuild_index` accumulates with `+=`, which PANICS on `Decimal`
1178 /// overflow — so two `Decimal::MAX` USD lots aborted inside `Deserialize`
1179 /// with "Addition overflowed" rather than returning a serde error. Review
1180 /// catch; a deserialization boundary must not panic on its input, the same
1181 /// rule that applies to the parser. The rebuild now uses `checked_add` and
1182 /// the failure arrives as `Err`, which serde reports as a normal
1183 /// deserialization error.
1184 fn try_from(wire: InventoryWire) -> Result<Self, Self::Error> {
1185 let mut inv = Self {
1186 positions: PositionStore::Owned(Slots::from_live(wire.positions.into_iter().collect())),
1187 units_cache: FxHashMap::default(),
1188 cost_index: FxHashMap::default(),
1189 ordered_index: None,
1190 undo_open: false,
1191 #[cfg(debug_assertions)]
1192 undo_witness: None,
1193 };
1194 // UNTRUSTED: the payload is input, not something this type produced, so
1195 // it may carry two cost-less lots for one currency — a state the
1196 // invariant forbids. `rebuild_index`'s `debug_assert` is there to catch
1197 // an internal bug; reaching it from deserialization would turn a
1198 // malformed document into a panic at the boundary.
1199 inv.try_rebuild_index_from(CacheSource::Untrusted)?;
1200 Ok(inv)
1201 }
1202}
1203
1204impl PartialEq for Inventory {
1205 fn eq(&self, other: &Self) -> bool {
1206 // Only compare positions, not the index (which is derived data)
1207 self.positions.iter().eq(other.positions.iter())
1208 }
1209}
1210
1211impl Eq for Inventory {}
1212
1213impl Inventory {
1214 /// Create an empty inventory.
1215 #[must_use]
1216 pub fn new() -> Self {
1217 Self::default()
1218 }
1219
1220 /// Iterate over all positions.
1221 ///
1222 /// Previously returned `&[Position]`; now returns an iterator
1223 /// because the underlying storage is a tree-based persistent
1224 /// vector (`imbl::Vector`) that doesn't expose a contiguous slice.
1225 /// Most callers already iterate — for callers that need
1226 /// random-access / indexed / `.len()` slice semantics, see
1227 /// [`Self::position_list`].
1228 pub fn positions(&self) -> impl Iterator<Item = &Position> + '_ {
1229 self.positions.iter()
1230 }
1231
1232 /// Materialize all positions as a `Vec<&Position>` for slice-style
1233 /// access (indexing, `.len()`, `.first()`, `.is_empty()`).
1234 ///
1235 /// Allocates `O(N)` pointers per call. Callers that only iterate
1236 /// once should use [`Self::positions`] instead — this is for code
1237 /// paths that need slice semantics.
1238 #[must_use]
1239 pub fn position_list(&self) -> Vec<&Position> {
1240 self.positions.iter().collect()
1241 }
1242
1243 /// Drop tombstones once they outnumber live lots, so slots stay within 2x
1244 /// the real position count and iteration cannot degrade toward "every lot
1245 /// this account ever held".
1246 ///
1247 /// Renumbers slots, so it must not run while an undo log is open or while
1248 /// any caller holds a slot index. The engine calls it after a transaction
1249 /// commits, which is the one moment both hold.
1250 ///
1251 /// Amortized: each compaction is O(slots) but halves them, so the cost per
1252 /// removed lot is constant.
1253 ///
1254 /// # Panics
1255 ///
1256 /// Panics in debug builds if an undo log is open.
1257 pub fn compact_if_sparse(&mut self) {
1258 debug_assert!(
1259 !self.undo_open,
1260 "compact_if_sparse would renumber slots the open undo log refers to",
1261 );
1262 if self.positions.dead() > self.positions.len() {
1263 self.positions.compact_slots();
1264 self.rebuild_index();
1265 }
1266 }
1267
1268 /// Begin recording an undo log so a failed transaction can be reverted
1269 /// without having copied this inventory.
1270 ///
1271 /// `apply` used to snapshot every touched account with `Inventory::clone`.
1272 /// That was written when the backing was `imbl::Vector` and the clone was
1273 /// O(1); since #2056 booking's backing is owned, so it became O(lots) per
1274 /// touched account per transaction — the largest superlinear term left in
1275 /// the pipeline, worth 56% of a 20,000-transaction `investment` run.
1276 ///
1277 /// A reduction touches one or two lots. Recording those is proportional to
1278 /// what changed instead of to what the account holds.
1279 ///
1280 /// # Panics
1281 ///
1282 /// Panics in debug builds if a log is already open — nesting would make
1283 /// "restore to the start" ambiguous.
1284 pub fn begin_undo(&mut self) {
1285 debug_assert!(
1286 !self.undo_open,
1287 "begin_undo called twice without commit or rollback",
1288 );
1289 // Recording only exists on the owned backing. A shared inventory would
1290 // set `undo_open` while capturing nothing, and rollback would then
1291 // restore nothing while reporting success — silent corruption rather
1292 // than a visible failure.
1293 //
1294 // Unreachable today: `BookingEngine` populates its map solely through
1295 // `entry().or_default()`, which is owned, and accepts no inventory from
1296 // outside. Asserted so it stays that way.
1297 debug_assert!(
1298 matches!(self.positions, PositionStore::Owned(_)),
1299 "begin_undo on a shared inventory would record nothing and roll \
1300 back nothing",
1301 );
1302 self.undo_open = true;
1303 #[cfg(debug_assertions)]
1304 {
1305 // The log is only as good as its coverage of the mutation paths.
1306 // Recording lives in the backing's primitives, which is complete by
1307 // construction today — this keeps it honest if that ever stops
1308 // being true, at a cost paid only in debug builds.
1309 self.undo_witness = Some(Box::new(self.clone_for_witness()));
1310 }
1311 self.positions.begin_undo();
1312 }
1313
1314 /// Whether an undo log is currently open.
1315 #[must_use]
1316 pub const fn undo_is_open(&self) -> bool {
1317 self.undo_open
1318 }
1319
1320 /// Discard the log — the transaction committed.
1321 pub fn commit_undo(&mut self) {
1322 self.undo_open = false;
1323 #[cfg(debug_assertions)]
1324 {
1325 self.undo_witness = None;
1326 }
1327 self.positions.commit_undo();
1328 }
1329
1330 /// Restore this inventory to its state at [`Self::begin_undo`].
1331 ///
1332 /// Rebuilds the derived caches wholesale rather than unwinding them: this
1333 /// is the failure path, so being obviously right beats being fast.
1334 ///
1335 /// # Panics
1336 ///
1337 /// Panics in debug builds if the result differs from a witness copy taken
1338 /// at `begin_undo` — that means a mutation path bypassed the log.
1339 pub fn rollback_undo(&mut self) {
1340 self.undo_open = false;
1341 self.positions.rollback_undo();
1342 self.rebuild_index();
1343 #[cfg(debug_assertions)]
1344 {
1345 if let Some(witness) = self.undo_witness.take() {
1346 let restored: Vec<&Position> = self.positions.iter().collect();
1347 let expected: Vec<&Position> = witness.positions.iter().collect();
1348 assert_eq!(
1349 restored, expected,
1350 "rollback did not restore the inventory: some mutation path \
1351 did not go through the backing's primitives, so the undo \
1352 log missed it",
1353 );
1354 }
1355 }
1356 }
1357
1358 /// A copy for the debug-only rollback witness.
1359 #[cfg(debug_assertions)]
1360 fn clone_for_witness(&self) -> Self {
1361 let mut copy = self.clone();
1362 copy.undo_witness = None;
1363 copy.undo_open = false;
1364 copy
1365 }
1366
1367 /// Rewrite the positions wholesale, then rebuild every derived cache.
1368 ///
1369 /// Replaces the old `positions_mut`, which handed out `&mut Vec<Position>`
1370 /// directly. That is no longer possible — the backing is sparse, so the
1371 /// vector holds `Option<Position>` alongside a live count, and a caller
1372 /// writing through it could desync that count with no way to notice.
1373 /// It also left `units_cache` and `simple_index` describing the OLD
1374 /// contents, which this rebuilds for you.
1375 ///
1376 /// Pre-1.0 break: the closure sees a dense `Vec<Position>` with tombstones
1377 /// already dropped, and whatever it leaves becomes the inventory.
1378 pub fn modify_positions(&mut self, f: impl FnOnce(&mut Vec<Position>)) {
1379 let mut dense: Vec<Position> = self.positions.iter().cloned().collect();
1380 f(&mut dense);
1381 self.positions = PositionStore::Owned(Slots::from_live(dense));
1382 self.rebuild_index();
1383 }
1384
1385 /// An inventory whose positions are structurally SHARED.
1386 ///
1387 /// For accumulators that are cloned far more often than they are mutated
1388 /// — BQL's JOURNAL running balance, which emits one snapshot per output
1389 /// row. Cloning is O(1) and successive snapshots share structure, so N
1390 /// rows cost O(base + sum of deltas) instead of O(N x base) (#1086).
1391 ///
1392 /// Everything else should use [`Inventory::new`]: the default contiguous
1393 /// backing is what makes booking's `reduce` cheap, and `reduce` converts
1394 /// to it anyway.
1395 #[must_use]
1396 pub fn new_shared() -> Self {
1397 Self {
1398 positions: PositionStore::Shared(Vector::new()),
1399 ..Self::default()
1400 }
1401 }
1402
1403 /// Check if inventory is empty.
1404 #[must_use]
1405 pub fn is_empty(&self) -> bool {
1406 self.positions.is_empty()
1407 || self
1408 .positions
1409 .iter()
1410 .all(super::position::Position::is_empty)
1411 }
1412
1413 /// Get the number of positions (including empty ones).
1414 #[must_use]
1415 pub fn len(&self) -> usize {
1416 self.positions.len()
1417 }
1418
1419 /// Get total units of a currency (ignoring cost lots).
1420 ///
1421 /// This sums all positions of the given currency regardless of cost basis.
1422 /// Uses an internal cache for O(1) lookups.
1423 #[must_use]
1424 pub fn units(&self, currency: &str) -> Decimal {
1425 // Use the cache when it is there. A miss is not a bug: the cache is
1426 // `#[serde(skip)]`, and while deserialization rebuilds it (the
1427 // `#[serde(try_from = "InventoryWire"]` on the struct), an inventory
1428 // built some other way may not have one yet. Recomputing is O(lots)
1429 // but always right.
1430 //
1431 // (This used to point callers at `rebuild_caches()`, which has never
1432 // existed under that name — `rebuild_index` is the real one, and
1433 // callers do not need it on the deserialize path any more.)
1434 self.units_cache.get(currency).map_or_else(
1435 || {
1436 // Fallback to computation if cache miss (e.g., after deserialization)
1437 self.positions
1438 .iter()
1439 .filter(|p| p.units.currency == currency)
1440 .map(|p| p.units.number)
1441 .sum()
1442 },
1443 |stats| stats.total,
1444 )
1445 }
1446
1447 /// Whether every `add` of `currency` totaling at most `needed` in absolute
1448 /// value is guaranteed not to overflow.
1449 ///
1450 /// `add` overflows at exactly two `checked_add`s: the per-currency running
1451 /// total, and — for a cost-less position — the single merged lot that
1452 /// `simple_index` points at. Both operands are bounded here against
1453 /// `needed`, so a `true` answer means no sequence of adds whose magnitudes
1454 /// sum to `needed` can overflow either, at any intermediate step: every
1455 /// partial sum is bounded by the total.
1456 ///
1457 /// Conservative by construction — `false` only ever means "cannot prove
1458 /// it", never "will overflow". Callers use it to skip work that exists
1459 /// solely to recover from overflow (#1897).
1460 #[must_use]
1461 pub fn add_headroom_for(&self, currency: &str, needed: Decimal) -> bool {
1462 // Enforce the magnitude contract rather than trusting it. A negative
1463 // `needed` would make the sums below SMALLER and hand back `true` when
1464 // overflow is possible — and an unsound `true` here means `apply`
1465 // skips the snapshot it needed, so earlier postings of a failing
1466 // transaction cannot be rolled back. Cheap insurance on a `pub` method
1467 // whose failure mode is silent corruption.
1468 let needed = needed.abs();
1469
1470 // `units_cache` and `simple_index` are `#[serde(skip)]`, so a
1471 // deserialized inventory carries its positions with both caches empty
1472 // until `rebuild_caches` runs. Reading them in that state answers
1473 // "plenty of room" for an inventory sitting at the ceiling — an
1474 // unsound `true`, which is the one direction this method must never
1475 // fail in. Refuse to answer instead. `units()` handles the same gap by
1476 // recomputing from `positions`; that is O(positions) and this is meant
1477 // to be O(1), so the conservative answer is the right trade here.
1478 if self.units_cache.is_empty() && !self.positions.is_empty() {
1479 return false;
1480 }
1481
1482 // `checked_add` is the whole test: it returns `None` exactly when the
1483 // sum is not representable, so any `Some` it hands back is already a
1484 // `Decimal` and therefore already `<= Decimal::MAX`. Comparing against
1485 // the ceiling as well was a tautology, and not a free one — `Decimal`'s
1486 // `PartialOrd` aligns the scales of both operands before it can answer,
1487 // and `Decimal::MAX` has scale 0 and a full 96-bit mantissa, so it was
1488 // the most expensive shape that comparison has. This runs twice per
1489 // posting via `overflow_is_possible`.
1490 let fits = |v: Decimal| v.abs().checked_add(needed).is_some();
1491
1492 // One lookup for both halves: the running total and the cost-less lot
1493 // a merge would land in.
1494 //
1495 // No entry means this inventory holds nothing of `currency`, so
1496 // nothing can overflow — and it is the same answer the two-map version
1497 // gave, which fell through to the lot check here. That fall-through
1498 // could never find anything: `add` writes the totals before the slot,
1499 // `rebuild_caches` writes both in one pass, and only the slot is ever
1500 // cleared, so a recorded slot always had a stats entry beside it. The
1501 // wholly-empty cache of a just-deserialized inventory is refused
1502 // above, which is the case where this reasoning would not hold.
1503 let Some(stats) = self.units_cache.get(currency) else {
1504 return true;
1505 };
1506 if !fits(stats.total) {
1507 return false;
1508 }
1509 // Only a cost-less add merges, and `simple_slot` names the one lot it
1510 // would merge into.
1511 stats
1512 .simple_slot
1513 .and_then(|idx| self.positions.get(idx))
1514 .is_none_or(|lot| fits(lot.units.number))
1515 }
1516
1517 /// Get all currencies in this inventory.
1518 #[must_use]
1519 pub fn currencies(&self) -> Vec<&str> {
1520 let mut currencies: Vec<&str> = self
1521 .positions
1522 .iter()
1523 .filter(|p| !p.is_empty())
1524 .map(|p| p.units.currency.as_str())
1525 .collect();
1526 currencies.sort_unstable();
1527 currencies.dedup();
1528 currencies
1529 }
1530
1531 /// Check if the given units would reduce (not augment) this inventory.
1532 ///
1533 /// Returns `true` if there's a position with the same currency but opposite
1534 /// sign, meaning these units would reduce the inventory rather than add to it.
1535 ///
1536 /// When `has_cost_spec` is `true`, only positions **with** a cost basis are
1537 /// considered for reduction matching. Simple (no-cost) positions are ignored
1538 /// because they live in a different "cost layer" — a sell-without-cost-spec
1539 /// that left a negative simple position should not cause a subsequent
1540 /// cost-bearing augmentation to be misclassified as a reduction.
1541 /// See: issue #875, beancount#889.
1542 ///
1543 /// This is used to determine whether a posting is a sale/reduction or a
1544 /// purchase/augmentation.
1545 #[must_use]
1546 pub fn is_reduced_by(&self, units: &Amount, scope: ReductionScope) -> bool {
1547 // `units_cache` is `#[serde(skip)]` like `simple_index`. An empty
1548 // one over non-empty positions means it has not been built yet, and
1549 // reading it then would answer "not a reduction" for an inventory that
1550 // holds matching lots — booking the posting as an augmentation and
1551 // silently creating a duplicate lot. Fall back to the scan, as
1552 // `units()` does for the same gap.
1553 if self.units_cache.is_empty() && !self.positions.is_empty() {
1554 return self.is_reduced_by_scan(units, scope);
1555 }
1556
1557 let answer = self.units_cache.get(&units.currency).is_some_and(|stats| {
1558 stats
1559 .counts
1560 .opposite(units.number.is_sign_positive(), scope)
1561 > 0
1562 });
1563
1564 // The index is maintained incrementally by `add` and the reduction
1565 // commit paths; a missed update is a wrong answer, not a slow one.
1566 debug_assert_eq!(
1567 answer,
1568 self.is_reduced_by_scan(units, scope),
1569 "the cached sign counts disagree with a scan of positions — some \
1570 mutation path changed a lot without maintaining them",
1571 );
1572 answer
1573 }
1574
1575 /// The scan [`Self::is_reduced_by`] replaced, kept as the definition the
1576 /// index is checked against and as the fallback for an unbuilt index.
1577 fn is_reduced_by_scan(&self, units: &Amount, scope: ReductionScope) -> bool {
1578 self.positions.iter().any(|pos| {
1579 pos.units.currency == units.currency
1580 && pos.units.number.is_sign_positive() != units.number.is_sign_positive()
1581 && match scope {
1582 ReductionScope::AllPositions => true,
1583 ReductionScope::CostBearingOnly => pos.cost.is_some(),
1584 }
1585 })
1586 }
1587
1588 /// Whether a posting of `units` carrying `cost` would REDUCE this inventory
1589 /// under `method` — the single source for the reduction-vs-augmentation
1590 /// decision shared by the booking engine (`BookingEngine::apply`) and the
1591 /// Late validator's inventory pass.
1592 ///
1593 /// A posting reduces only when it carries a cost spec (`cost.is_some()` —
1594 /// presence of the spec, which includes an empty/unresolved one like `{}`),
1595 /// the booking method isn't `NONE` (issue #1182 — `NONE` accumulates every
1596 /// posting as an augmentation, with no lot matching), and the inventory holds
1597 /// a cost-bearing position of the opposite sign in the same currency
1598 /// ([`Self::is_reduced_by`] with [`ReductionScope::CostBearingOnly`]). This
1599 /// gate was previously written byte-for-byte in both crates and the #1182 fix
1600 /// had to be applied twice.
1601 #[must_use]
1602 pub fn is_booking_reduction(
1603 &self,
1604 units: &Amount,
1605 cost: Option<&CostSpec>,
1606 method: BookingMethod,
1607 ) -> bool {
1608 method != BookingMethod::None
1609 && cost.is_some()
1610 && self.is_reduced_by(units, ReductionScope::CostBearingOnly)
1611 }
1612
1613 /// Get the total book value (cost basis) for a currency.
1614 ///
1615 /// Returns the sum of all cost bases for positions of the given currency.
1616 ///
1617 /// # Errors
1618 ///
1619 /// [`OverflowError`] when a position's book value, or the running
1620 /// per-currency total, leaves `rust_decimal`'s range.
1621 pub fn book_value(
1622 &self,
1623 units_currency: &str,
1624 ) -> Result<FxHashMap<crate::Currency, Decimal>, OverflowError> {
1625 let mut totals: FxHashMap<crate::Currency, Decimal> = FxHashMap::default();
1626
1627 for pos in self.positions.iter() {
1628 if pos.units.currency == units_currency {
1629 // NOT `pos.book_value()`: its `None` conflates "no cost" with
1630 // "product out of range", and skipping the latter would drop a
1631 // position from the total silently — the same class of bug as
1632 // clamping it (#1863).
1633 let Some(cost) = pos.cost.as_ref() else {
1634 continue;
1635 };
1636 let overflow = || OverflowError {
1637 currency: cost.currency.clone(),
1638 };
1639 let book = cost.total_cost(pos.units.number).ok_or_else(overflow)?;
1640 let slot = totals.entry(book.currency.clone()).or_default();
1641 *slot = slot.checked_add(book.number).ok_or_else(overflow)?;
1642 }
1643 }
1644
1645 Ok(totals)
1646 }
1647
1648 /// Add a position to the inventory.
1649 ///
1650 /// For positions without cost, this merges with existing positions
1651 /// of the same currency using O(1) `HashMap` lookup.
1652 ///
1653 /// For positions with cost, this adds as a new lot (O(1)).
1654 /// Lot aggregation for display purposes is handled separately at output time
1655 /// (e.g., in the query result formatter).
1656 ///
1657 /// # TLA+ Specification
1658 ///
1659 /// Implements `AddAmount` action from `Conservation.tla`:
1660 /// - Invariant: `inventory + totalReduced = totalAdded`
1661 /// - After add: `totalAdded' = totalAdded + amount`
1662 ///
1663 /// See: `spec/tla/Conservation.tla`
1664 ///
1665 /// # Errors
1666 ///
1667 /// [`OverflowError`] when the running total for this currency leaves
1668 /// `rust_decimal`'s ~±7.9e28 range. The inventory is left UNCHANGED — the
1669 /// units cache is only committed once the merge is known to fit, so a
1670 /// caller that reports the error and moves on does not carry a
1671 /// half-applied position (#1863).
1672 pub fn add(&mut self, position: Position) -> Result<(), OverflowError> {
1673 if position.is_empty() {
1674 return Ok(());
1675 }
1676
1677 let overflow = || OverflowError {
1678 currency: position.units.currency.clone(),
1679 };
1680
1681 // Compute both running totals BEFORE mutating anything, so an overflow
1682 // leaves the inventory untouched rather than half-updated.
1683 let cached = self
1684 .units_cache
1685 .get(&position.units.currency)
1686 .map(|s| s.total)
1687 .unwrap_or_default();
1688 // Python `decimal` scale semantics, not raw `checked_add` — see
1689 // `crate::decimal::add_python_scale`. `rust_decimal` returns the other
1690 // operand untouched when one side is zero, so a running total that
1691 // passes through zero drops its scale and everything added after it
1692 // renders one scale narrower. That made a coalesced balance
1693 // ORDER-DEPENDENT: the same postings in a different order produced
1694 // `1` or `1.00` for the same money.
1695 let new_cached = crate::decimal::checked_add_python_scale(cached, position.units.number)
1696 .ok_or_else(overflow)?;
1697
1698 let merge_idx = position
1699 .cost
1700 .is_none()
1701 .then(|| {
1702 self.units_cache
1703 .get(&position.units.currency)
1704 .and_then(|s| s.simple_slot)
1705 })
1706 .flatten();
1707 let merged_units = merge_idx
1708 .map(|idx| {
1709 // Same rule as the units cache above — these two must agree,
1710 // or `units()` and the position itself report different scales
1711 // for the same currency.
1712 crate::decimal::checked_add_python_scale(
1713 self.positions[idx].units.number,
1714 position.units.number,
1715 )
1716 .ok_or_else(overflow)
1717 })
1718 .transpose()?;
1719
1720 // Bucket changes, worked out before touching the cache so the whole
1721 // update lands in ONE lookup below. A cost-less merge can flip the
1722 // lot's sign (adding -8 to a +3 lot), which moves it between buckets;
1723 // `is_sign_positive` answers true for zero, matching the predicate
1724 // `is_reduced_by` uses.
1725 let vacated = merge_idx.map(|idx| {
1726 let lot = &self.positions[idx];
1727 (lot.cost.is_some(), lot.units.number.is_sign_positive())
1728 });
1729 let occupied = (
1730 position.cost.is_some(),
1731 merged_units
1732 .unwrap_or(position.units.number)
1733 .is_sign_positive(),
1734 );
1735
1736 // ONE mutable lookup for the total AND the counts. `add` runs once per
1737 // posting, and the units cache is keyed by an interned string whose
1738 // `Hash` walks its bytes — this used to be a `get` plus an `insert`,
1739 // and hanging the counts off a second map made it three hashes per
1740 // posting, which measured as a regression on ledgers that book no
1741 // cost specs. `get_mut` first so only a currency's first lot pays for
1742 // an owned key.
1743 if let Some(stats) = self.units_cache.get_mut(&position.units.currency) {
1744 stats.total = new_cached;
1745 if let Some((had_cost, was_positive)) = vacated {
1746 stats.counts.bump(had_cost, was_positive, -1);
1747 }
1748 stats.counts.bump(occupied.0, occupied.1, 1);
1749 } else {
1750 // No entry yet means no lot of this currency has ever been added,
1751 // so there is nothing to vacate: `merge_idx` came from
1752 // `simple_index`, which only names a lot that `add` already
1753 // counted.
1754 debug_assert!(
1755 vacated.is_none(),
1756 "merging into a lot whose currency has no cached entry",
1757 );
1758 let mut counts = SignCounts::default();
1759 counts.bump(occupied.0, occupied.1, 1);
1760 self.units_cache.insert(
1761 position.units.currency.clone(),
1762 CurrencyStats {
1763 total: new_cached,
1764 counts,
1765 simple_slot: None,
1766 },
1767 );
1768 }
1769
1770 // For positions without cost, use index for O(1) lookup
1771 if position.cost.is_none() {
1772 if let Some(idx) = merge_idx {
1773 // Merge with existing position
1774 debug_assert!(self.positions[idx].cost.is_none());
1775 self.positions[idx].units.number =
1776 merged_units.expect("merged_units is Some whenever merge_idx is");
1777 return Ok(());
1778 }
1779 // No existing position - add new one and index it
1780 // `push_slot`, not `len()`: with tombstones present the live
1781 // count is not the slot the lot lands in, and `simple_index`
1782 // stores slots.
1783 let currency = position.units.currency.clone();
1784 let idx = self.positions.push_slot(position);
1785 // The stats entry exists: the totals above were written before
1786 // this point for every currency that reaches here.
1787 self.units_cache.entry(currency).or_default().simple_slot = Some(idx);
1788 return Ok(());
1789 }
1790
1791 // For positions with cost, just add as a new lot.
1792 // This is O(1) and keeps all lots separate, matching Python beancount behavior.
1793 // Lot aggregation for display purposes is handled separately in query output.
1794 let key = cost_key(&position);
1795 // Every position, not only cost-bearing ones: an empty cost spec
1796 // matches a cost-less lot (`matches_cost_spec`: `(None, true)`), so
1797 // ordered selection can drain one, and an index that omitted them
1798 // picked a different lot than the scan.
1799 let ordering = position.units.currency.clone();
1800 let slot = self.positions.push_slot(position);
1801 if let Some(key) = key {
1802 self.cost_index.entry(key).or_default().push(slot);
1803 }
1804 self.ordered_index_insert(&ordering, slot);
1805 Ok(())
1806 }
1807
1808 /// Adjust `sign_index` for the position currently at `idx` by `delta`.
1809 ///
1810 /// Called with `-1` before changing or removing a lot and `+1` after, so
1811 /// a sign flip lands in the right bucket.
1812 /// Drop `idx` from [`Self::cost_index`]. Called wherever a lot is
1813 /// tombstoned, since the slot stays valid but the lot is gone.
1814 pub(super) fn cost_index_remove(&mut self, idx: usize) {
1815 let Some(position) = self.positions.get(idx) else {
1816 return;
1817 };
1818 // Only pay for the ordered index when one has been built: this runs on
1819 // every drained lot, and cloning the currency to probe a map that is
1820 // not there is pure overhead for a ledger that never reduces with an
1821 // under-specified spec.
1822 let ordered = self
1823 .ordered_index
1824 .is_some()
1825 .then(|| position.units.currency.clone());
1826 if let Some(key) = cost_key(position)
1827 && let Some(slots) = self.cost_index.get_mut(&key)
1828 {
1829 slots.retain(|slot| *slot != idx);
1830 if slots.is_empty() {
1831 self.cost_index.remove(&key);
1832 }
1833 }
1834 // The list is ordered, so find the entry rather than scanning for it:
1835 // a FIFO account drains its oldest lot over and over, and `retain`
1836 // walked every lot each time.
1837 let Some(currency) = ordered else {
1838 return;
1839 };
1840 let Some(index) = self.ordered_index.as_mut() else {
1841 return;
1842 };
1843 if let Some(slots) = index.by_currency.get_mut(¤cy) {
1844 // Linear here rather than a binary search: `order_key` needs
1845 // `&self.positions`, which is already borrowed through `index`.
1846 // Removal is off the hot path — the walk is what this index exists
1847 // to speed up — and it keeps the ordering rule in one place.
1848 let at = slots.iter().position(|&existing| existing == idx);
1849 if let Some(at) = at {
1850 slots.remove(at);
1851 }
1852 if slots.is_empty() {
1853 index.by_currency.remove(¤cy);
1854 }
1855 }
1856 }
1857
1858 /// Place `slot` under `currency`, keeping the list in (date, slot) order.
1859 ///
1860 /// Ledgers book in date order, so the new lot almost always belongs at the
1861 /// end and the search settles immediately; the binary search is what keeps
1862 /// an out-of-order lot correct rather than fast.
1863 fn ordered_index_insert(&mut self, currency: &crate::Currency, slot: usize) {
1864 // Maintain only an index that has been built. A ledger whose
1865 // reductions all resolve through `cost_index` never builds one and so
1866 // never pays for it: maintaining it from every `add` unconditionally
1867 // cost 6% on the `investment` shape, which never reads it.
1868 if !matches!(self.positions, PositionStore::Owned(_)) {
1869 return;
1870 }
1871 let Some(mut index) = self.ordered_index.take() else {
1872 return;
1873 };
1874 let order = index.order;
1875 let key = (self.order_key(order, slot), slot);
1876 // The index is OUT of `self` for the search, so the binary search can
1877 // read `self.positions` for each probe. Materializing the keys instead
1878 // — the obvious way around the borrow — makes every `add` walk the
1879 // whole currency, which is the quadratic this index exists to remove.
1880 let entry = index.by_currency.entry(currency.clone()).or_default();
1881 let at =
1882 entry.partition_point(|&existing| (self.order_key(order, existing), existing) < key);
1883 entry.insert(at, slot);
1884 self.ordered_index = Some(index);
1885 }
1886
1887 /// Populate `ordered_index` from the current lots — all of them, cost-less
1888 /// included, because an empty cost spec selects those too.
1889 ///
1890 /// Called the first time an ordered booking method reduces against this
1891 /// inventory, then kept current incrementally. Ordering matches what
1892 /// `plan_ordered` produced when it sorted per call: date ascending, slot
1893 /// ascending within a date.
1894 /// The value `order` sorts `slot` by. Ties fall through to the slot
1895 /// number, which is what makes both orderings match the stable sorts they
1896 /// replace: `sort_by_key(date)` and `sort_by_key(Reverse(cost))` both left
1897 /// equal keys in ascending slot order.
1898 fn order_key(
1899 &self,
1900 order: LotOrder,
1901 slot: usize,
1902 ) -> (Option<Decimal>, Option<crate::NaiveDate>) {
1903 let cost = self.positions.get(slot).and_then(|p| p.cost.as_ref());
1904 match order {
1905 LotOrder::Date => (None, cost.and_then(|c| c.date)),
1906 // Negated rather than reversed so the tuple still sorts ascending
1907 // and the slot tiebreak keeps its direction.
1908 //
1909 // A cost-less lot counts as zero rather than as `None`. `None`
1910 // sorts BEFORE `Some`, which would put cost-less lots at the front
1911 // of a highest-cost-first walk — the opposite of where the
1912 // `map_or(Decimal::ZERO, ..)` this replaces put them. An empty cost
1913 // spec matches a cost-less position, so HIFO can reach one.
1914 LotOrder::CostDescending => (Some(-cost.map_or(Decimal::ZERO, |c| c.number)), None),
1915 }
1916 }
1917
1918 pub(super) fn build_ordered_index(&mut self, order: LotOrder) {
1919 if !matches!(self.positions, PositionStore::Owned(_)) {
1920 return;
1921 }
1922 let mut by_currency: FxHashMap<crate::Currency, Vec<usize>> = FxHashMap::default();
1923 for (idx, pos) in self.positions.iter_slots() {
1924 by_currency
1925 .entry(pos.units.currency.clone())
1926 .or_default()
1927 .push(idx);
1928 }
1929 for slots in by_currency.values_mut() {
1930 slots.sort_by_key(|&idx| self.order_key(order, idx));
1931 }
1932 self.ordered_index = Some(Box::new(OrderedIndex { order, by_currency }));
1933 }
1934
1935 /// Every slot of `currency` in FIFO order, or `None` when the index cannot
1936 /// answer and the caller must scan.
1937 ///
1938 /// Cost-less slots included — see the field's own note on why.
1939 fn ordered_candidates(&self, currency: &crate::Currency, order: LotOrder) -> Option<&[usize]> {
1940 let index = self.ordered_index.as_ref()?;
1941 // A different ordering answers a different question; scanning is the
1942 // only correct fallback until something rebuilds it.
1943 if index.order != order {
1944 return None;
1945 }
1946 Some(
1947 index
1948 .by_currency
1949 .get(currency)
1950 .map_or(&[][..], Vec::as_slice),
1951 )
1952 }
1953
1954 /// Slots that could satisfy `spec` for `units`, or `None` when the spec
1955 /// names no per-unit cost and therefore every lot is a candidate.
1956 ///
1957 /// Returned ascending so callers see the same order a scan would.
1958 fn cost_candidates(&self, units: &Amount, spec: &CostSpec) -> Option<Vec<usize>> {
1959 // An empty index means it was never built for this inventory — a
1960 // shared snapshot, or one that has not been rebuilt since. Scanning is
1961 // always correct, and answering from an index that is missing entries
1962 // is NOT: the lot would never reach the predicate. Falling back keeps
1963 // the only failure mode the harmless one.
1964 if self.cost_index.is_empty() {
1965 return None;
1966 }
1967 let number = spec.number.and_then(|n| n.per_unit())?;
1968 let currency = spec.currency.clone()?;
1969 let mut slots = self
1970 .cost_index
1971 .get(&(units.currency.clone(), number, currency))
1972 .cloned()
1973 .unwrap_or_default()
1974 .to_vec();
1975 slots.sort_unstable();
1976 Some(slots)
1977 }
1978
1979 pub(super) fn sign_index_bump(&mut self, idx: usize, delta: i32) {
1980 // All three call sites pass an index they just read or wrote, so this
1981 // is defensive only. Returning rather than panicking keeps a future
1982 // caller's off-by-one out of the panic path; the counts then disagree
1983 // with a scan, which `is_reduced_by`'s assertion reports in debug.
1984 debug_assert!(
1985 idx < self.positions.slot_count(),
1986 "sign_index_bump called with out-of-range index {idx}",
1987 );
1988 let Some(position) = self.positions.get(idx) else {
1989 return;
1990 };
1991 // Read the two bits the bucket depends on and drop the borrow. Cloning
1992 // the `Position` here instead — which is what the obvious version does
1993 // to satisfy the borrow checker — costs an `Arc` bump per currency plus
1994 // the lot's label on EVERY add, and this runs on the hot path.
1995 let has_cost = position.cost.is_some();
1996 let is_positive = position.units.number.is_sign_positive();
1997 if let Some(stats) = self.units_cache.get_mut(&position.units.currency) {
1998 stats.counts.bump(has_cost, is_positive, delta);
1999 }
2000 // No entry means no lots of this currency have been counted yet, which
2001 // only happens before `add` records the total. `add` inserts the entry
2002 // before calling this, and the rebuild path fills both together.
2003 }
2004
2005 /// Reduce positions from the inventory using the specified booking method.
2006 ///
2007 /// # Arguments
2008 ///
2009 /// * `units` - The units to reduce (negative for selling)
2010 /// * `cost_spec` - Optional cost specification for matching lots
2011 /// * `method` - The booking method to use
2012 ///
2013 /// # Returns
2014 ///
2015 /// Returns a `BookingResult` with the matched positions and cost basis,
2016 /// or a `BookingError` if the reduction cannot be performed.
2017 ///
2018 /// # TLA+ Specification
2019 ///
2020 /// Implements `ReduceAmount` action from `Conservation.tla`:
2021 /// - Invariant: `inventory + totalReduced = totalAdded`
2022 /// - After reduce: `totalReduced' = totalReduced + amount`
2023 /// - Precondition: `amount <= inventory` (else `InsufficientUnits` error)
2024 ///
2025 /// Lot selection follows these TLA+ specs based on `method`:
2026 /// - `Fifo`: `FIFOCorrect.tla` - Oldest lots first (`selected_date <= all other dates`)
2027 /// - `Lifo`: `LIFOCorrect.tla` - Newest lots first (`selected_date >= all other dates`)
2028 /// - `Hifo`: `HIFOCorrect.tla` - Highest cost first (`selected_cost >= all other costs`)
2029 ///
2030 /// See: `spec/tla/Conservation.tla`, `spec/tla/FIFOCorrect.tla`, etc.
2031 pub fn reduce(
2032 &mut self,
2033 units: &Amount,
2034 cost_spec: Option<&CostSpec>,
2035 method: BookingMethod,
2036 ) -> Result<BookingResult, BookingError> {
2037 let spec = cost_spec.cloned().unwrap_or_default();
2038
2039 // Force a uniquely-owned positions Vector before any reduction mutates
2040 // it. `self.positions` MAY be structurally shared — BQL snapshots build
2041 // `Shared` stores via `Inventory::new_shared` — and every reduction
2042 // method below mutates it in place (via `IndexMut` / `retain`).
2043 //
2044 // The sharing comes from BQL, not from booking. Since #2056 the store
2045 // is a hybrid and `PositionStore::default()` is `Owned(Vec)`, so the
2046 // booking engine's inventories are owned and any copy of one is a
2047 // DEEP O(lots) copy rather than an imbl O(1) one. This comment
2048 // asserted the opposite until #2061, and that wrong claim is a good
2049 // part of why the copy went unexamined for so long — `Position::clone`
2050 // was growing 104x for 10x the input on the `investment` profiling
2051 // shape.
2052 //
2053 // `BookingEngine::book` no longer takes such a copy per transaction —
2054 // it previews through `try_reduce`, which computes from `&self` via
2055 // the `plan_*` halves in `booking.rs`, and copies only for an account
2056 // with more than one reducing posting in the same transaction.
2057 //
2058 // Mutating a SHARED imbl `Vector` in place drives
2059 // `imbl-sized-chunks`' copy-on-write into a use-after-free of the
2060 // interned `Arc<str>` inside `Position` — heap corruption / SIGSEGV on
2061 // large ledgers with many lot reductions (found by the rich-workload
2062 // profiler). Rebuilding from cloned positions restores a refcount-1
2063 // Vector with correct `Arc` refcounting, so in-place mutation below has
2064 // no shared chunk to corrupt.
2065 self.positions.make_owned();
2066
2067 // Compaction does NOT run here. It renumbers slots, which would
2068 // invalidate an open undo log — and `apply` keeps one across the whole
2069 // transaction. `compact_if_sparse` is called by the engine after a
2070 // transaction commits, which is the only moment no slot index is held
2071 // and no rollback can still be required.
2072 // Compact here UNLESS a transaction is in flight. Compaction renumbers
2073 // slots and an open undo log refers to them, so `apply` defers it to
2074 // commit — but every other caller reduces without a log, and tying
2075 // compaction to `apply` alone would leave those inventories growing a
2076 // dead slot per closed lot forever.
2077 //
2078 // That is not hypothetical: the Late validator keeps its own
2079 // inventories across transactions and calls `reduce` directly, so it
2080 // would have accumulated one tombstone per sale for the life of the
2081 // ledger and scanned all of them on every reduction.
2082 //
2083 // No assertion about the tombstone ratio: a `{*}` merge legitimately
2084 // tombstones every matched lot and pushes one, so dead slots CAN
2085 // outnumber live ones mid-transaction.
2086 if !self.undo_open {
2087 self.compact_if_sparse();
2088 }
2089
2090 // Ordered selection walks lots in date order, so give it the index
2091 // that holds them that way — built here, on the first reduction that
2092 // will actually read it, and maintained incrementally afterwards. A
2093 // STRICT account resolves through `cost_index` instead and never
2094 // reaches this, which is why the build is gated rather than
2095 // unconditional (#2083).
2096 // Which ordering this account's method consumes, if any. STRICT
2097 // resolves through `cost_index` instead and never reaches the walk, so
2098 // it builds nothing.
2099 let wanted_order = match method {
2100 BookingMethod::Fifo | BookingMethod::Lifo => Some(LotOrder::Date),
2101 BookingMethod::Hifo => Some(LotOrder::CostDescending),
2102 _ => None,
2103 };
2104 if let Some(order) = wanted_order
2105 && self.ordered_index.as_ref().is_none_or(|i| i.order != order)
2106 {
2107 self.build_ordered_index(order);
2108 }
2109
2110 // {*} merge operator: merge all lots into a single weighted-average-cost
2111 // lot before reducing, regardless of the account's booking method.
2112 if spec.merge {
2113 return self.reduce_merge(units);
2114 }
2115
2116 match method {
2117 BookingMethod::Strict => self.reduce_strict(units, &spec),
2118 BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
2119 BookingMethod::Fifo => self.reduce_fifo(units, &spec),
2120 BookingMethod::Lifo => self.reduce_lifo(units, &spec),
2121 BookingMethod::Hifo => self.reduce_hifo(units, &spec),
2122 BookingMethod::Average => self.reduce_average(units),
2123 BookingMethod::None => self.reduce_none(units),
2124 }
2125 }
2126
2127 /// Remove all empty positions.
2128 pub fn compact(&mut self) {
2129 self.positions.retain(|p| !p.is_empty());
2130 self.rebuild_index();
2131 }
2132
2133 /// Rebuild all caches (`simple_index` and `units_cache`) from positions.
2134 ///
2135 /// Called after operations that may invalidate them (`compact`'s retain) and
2136 /// on deserialization, which is what [`CacheSource`] distinguishes.
2137 fn rebuild_index(&mut self) {
2138 // Internal positions came through `add`, which already rejected any
2139 // sum that would overflow, so this cannot fail. Asserted rather than
2140 // ignored: a failure here would mean `add`'s check had a hole.
2141 // Call FIRST, assert on the result. Putting the call inside
2142 // `debug_assert!` compiles the rebuild itself out of release builds,
2143 // so `compact` would have left the caches stale — caught by clippy's
2144 // `debug_assert_with_mut_call`.
2145 let rebuilt = self.try_rebuild_index_from(CacheSource::Internal);
2146 debug_assert!(
2147 rebuilt.is_ok(),
2148 "internal positions summed past the Decimal range; `add` should \
2149 have rejected them",
2150 );
2151 }
2152
2153 fn try_rebuild_index_from(&mut self, source: CacheSource) -> Result<(), OverflowError> {
2154 self.units_cache.clear();
2155 self.cost_index.clear();
2156 // Preserve whether the ordered index has been BUILT, rather than
2157 // building it here. A rebuild happens on compaction and on rollback,
2158 // neither of which means ordered selection is in use — repopulating
2159 // unconditionally handed the index (and its maintenance cost) to every
2160 // ledger, including the ones whose reductions all resolve through
2161 // `cost_index`.
2162 let ordered_was_built = self.ordered_index.as_ref().map(|i| i.order);
2163 self.ordered_index = None;
2164
2165 // The cost index is for BOOKING, and only the owned backing books.
2166 //
2167 // Not a micro-optimization: `Inventory` derives `Clone` and BQL clones
2168 // a shared snapshot ONCE PER OUTPUT ROW (`running_balance.clone()` in
2169 // the executor). This map holds roughly an entry per distinct cost, so
2170 // building it for shared inventories would put O(lots) back into every
2171 // per-row clone — the O(rows x lots) blow-up that #1086 is about and
2172 // that the shared backing exists to avoid. Snapshots keep an empty map
2173 // and clone it for free.
2174 let index_costs = matches!(self.positions, PositionStore::Owned(_));
2175
2176 for (idx, pos) in self.positions.iter_slots() {
2177 if index_costs {
2178 if let Some(key) = cost_key(pos) {
2179 self.cost_index.entry(key).or_default().push(idx);
2180 }
2181 if let Some(order) = ordered_was_built {
2182 self.ordered_index
2183 .get_or_insert_with(|| {
2184 Box::new(OrderedIndex {
2185 order,
2186 by_currency: FxHashMap::default(),
2187 })
2188 })
2189 .by_currency
2190 .entry(pos.units.currency.clone())
2191 .or_default()
2192 .push(idx);
2193 }
2194 }
2195 // Update units cache for all positions. Checked, not `+=`:
2196 // `Decimal`'s `+` panics on overflow, and this runs over payloads.
2197 //
2198 // Must apply the SAME Python-scale rule as `add`, not a raw
2199 // `checked_add`. `units_cache` is `#[serde(skip)]`, so this is the
2200 // path that reconstructs it after a round-trip; if the two
2201 // disagreed, an inventory built incrementally and the same
2202 // inventory deserialized would report different scales for the
2203 // same money — measured at `1.00` built vs `1` rebuilt, across
2204 // three cost lots summing through zero. Pinned by
2205 // `a_round_trip_reports_the_same_scale_as_incremental_adds`.
2206 let slot = self
2207 .units_cache
2208 .entry(pos.units.currency.clone())
2209 .or_default();
2210 slot.counts
2211 .bump(pos.cost.is_some(), pos.units.number.is_sign_positive(), 1);
2212 slot.total = crate::decimal::checked_add_python_scale(slot.total, pos.units.number)
2213 .ok_or_else(|| OverflowError {
2214 currency: pos.units.currency.clone(),
2215 })?;
2216
2217 // Record the cost-less lot only for positions without cost
2218 if pos.cost.is_none() {
2219 debug_assert!(
2220 source == CacheSource::Untrusted
2221 || self
2222 .units_cache
2223 .get(&pos.units.currency)
2224 .is_none_or(|s| s.simple_slot.is_none()),
2225 "Invariant violated: multiple simple positions for currency {}",
2226 pos.units.currency
2227 );
2228 // Last-wins on a duplicate, matching the pre-existing behavior
2229 // of this write. `units_cache` sums every position either way,
2230 // so the total stays right; only which lot a later cost-less
2231 // `add` merges into is affected.
2232 self.units_cache
2233 .entry(pos.units.currency.clone())
2234 .or_default()
2235 .simple_slot = Some(idx);
2236 }
2237 }
2238
2239 // The walk above pushed in slot order; ordered selection wants date
2240 // order with slot as the tiebreak. `sort_by_key` is stable, so the
2241 // slot order already there survives — the same two-level order
2242 // `plan_ordered` produced when it sorted per call.
2243 if let Some(order) = ordered_was_built {
2244 let keys: Vec<(crate::Currency, Vec<usize>)> = self
2245 .ordered_index
2246 .as_ref()
2247 .map(|i| {
2248 i.by_currency
2249 .iter()
2250 .map(|(c, slots)| (c.clone(), slots.clone()))
2251 .collect()
2252 })
2253 .unwrap_or_default();
2254 for (currency, mut slots) in keys {
2255 slots.sort_by_key(|&idx| self.order_key(order, idx));
2256 if let Some(index) = self.ordered_index.as_mut() {
2257 index.by_currency.insert(currency, slots);
2258 }
2259 }
2260 }
2261 Ok(())
2262 }
2263
2264 /// Merge this inventory with another.
2265 ///
2266 /// # Errors
2267 ///
2268 /// [`OverflowError`] when a merged running total leaves `rust_decimal`'s
2269 /// range. `self` keeps the positions merged before the failure.
2270 pub fn merge(&mut self, other: &Self) -> Result<(), OverflowError> {
2271 for pos in other.positions.iter() {
2272 self.add(pos.clone())?;
2273 }
2274 Ok(())
2275 }
2276
2277 /// Convert inventory to cost basis.
2278 ///
2279 /// Returns a new inventory where all positions are converted to their
2280 /// cost basis. Positions without cost are returned as-is.
2281 ///
2282 /// # Errors
2283 ///
2284 /// [`OverflowError`] when a `units × cost` product, or the running total
2285 /// of those products, leaves `rust_decimal`'s range. Note this can fire on
2286 /// inputs far below the ceiling — the product overflows when neither
2287 /// operand does.
2288 pub fn at_cost(&self) -> Result<Self, OverflowError> {
2289 let mut result = Self::new();
2290
2291 for pos in self.positions.iter() {
2292 if pos.is_empty() {
2293 continue;
2294 }
2295
2296 if let Some(cost) = &pos.cost {
2297 // Convert to cost basis
2298 let total =
2299 pos.units
2300 .number
2301 .checked_mul(cost.number)
2302 .ok_or_else(|| OverflowError {
2303 currency: cost.currency.clone(),
2304 })?;
2305 result.add(Position::simple(Amount::new(total, &cost.currency)))?;
2306 } else {
2307 // No cost, keep as-is
2308 result.add(pos.clone())?;
2309 }
2310 }
2311
2312 Ok(result)
2313 }
2314
2315 /// Convert inventory to units only.
2316 ///
2317 /// Returns a new inventory where all positions have their cost removed,
2318 /// effectively aggregating by currency only.
2319 ///
2320 /// # Errors
2321 ///
2322 /// [`OverflowError`] when stripping costs merges lots whose combined units
2323 /// leave `rust_decimal`'s range.
2324 pub fn at_units(&self) -> Result<Self, OverflowError> {
2325 let mut result = Self::new();
2326
2327 for pos in self.positions.iter() {
2328 if pos.is_empty() {
2329 continue;
2330 }
2331
2332 // Strip cost, keep only units
2333 result.add(Position::simple(pos.units.clone()))?;
2334 }
2335
2336 Ok(result)
2337 }
2338}
2339
2340/// Sum the units of `currency` across `account` AND all of its sub-accounts,
2341/// over a map of per-account inventories.
2342///
2343/// Beancount's `balance Assets:Bank` assertion — and the pad math that targets
2344/// it — includes `Assets:Bank:Checking`, `Assets:Bank:Savings`, etc. (verified
2345/// against `bean-check`: an assertion on a parent passes when the balance is held
2346/// in a sub-account). Sub-account membership uses [`is_subaccount_or_equal`], so
2347/// the segment-boundary rule (`Assets:BankAlias` does NOT match `Assets:Bank`)
2348/// is shared.
2349///
2350/// This is the single source for that sum, used by both the booking pad engine
2351/// and the Late balance validator. They previously computed the pad/assertion
2352/// difference differently — booking summed only the leaf account
2353/// (`Inventory::units`) while the validator summed sub-accounts — so a pad
2354/// targeting a non-leaf account inserted the wrong synthetic amount.
2355///
2356/// Returns `None` when the sum leaves `rust_decimal`'s range (`Decimal`'s
2357/// `Sum` impl panics rather than wrapping). Both callers surface that as a
2358/// diagnostic on the assertion/pad rather than asserting against a clamped
2359/// total (#1863).
2360pub fn sum_account_and_subaccounts<'a, I>(
2361 inventories: I,
2362 account: &str,
2363 currency: &Currency,
2364) -> Option<Decimal>
2365where
2366 I: IntoIterator<Item = (&'a Account, &'a Inventory)>,
2367{
2368 inventories
2369 .into_iter()
2370 .filter(|(inv_account, _)| is_subaccount_or_equal(inv_account.as_str(), account))
2371 .try_fold(Decimal::ZERO, |acc, (_, inv)| {
2372 acc.checked_add(inv.units(currency))
2373 })
2374}
2375
2376impl fmt::Display for Inventory {
2377 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2378 if self.is_empty() {
2379 return write!(f, "(empty)");
2380 }
2381
2382 // Sort positions alphabetically by currency, then by cost for consistency
2383 let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
2384 non_empty.sort_by(|a, b| {
2385 // First by currency
2386 let cmp = a.units.currency.cmp(&b.units.currency);
2387 if cmp != std::cmp::Ordering::Equal {
2388 return cmp;
2389 }
2390 // Then by cost (if present)
2391 match (&a.cost, &b.cost) {
2392 (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
2393 (Some(_), None) => std::cmp::Ordering::Greater,
2394 (None, Some(_)) => std::cmp::Ordering::Less,
2395 (None, None) => std::cmp::Ordering::Equal,
2396 }
2397 });
2398
2399 for (i, pos) in non_empty.iter().enumerate() {
2400 if i > 0 {
2401 write!(f, ", ")?;
2402 }
2403 write!(f, "{pos}")?;
2404 }
2405 Ok(())
2406 }
2407}
2408
2409impl Inventory {
2410 /// Build an inventory from positions.
2411 ///
2412 /// Replaces the former `FromIterator<Position>` impl, which was removed
2413 /// deliberately: `from_iter` cannot report failure, so it had to swallow
2414 /// the overflow from [`Self::add`] and hand back an inventory holding a
2415 /// wrong total with nothing to indicate it (#1863). A `collect()` that can
2416 /// silently lie is worse than no `collect()`.
2417 ///
2418 /// # Errors
2419 ///
2420 /// [`OverflowError`] when a running total leaves `rust_decimal`'s range.
2421 pub fn try_from_positions<I>(iter: I) -> Result<Self, OverflowError>
2422 where
2423 I: IntoIterator<Item = Position>,
2424 {
2425 let mut inv = Self::new();
2426 for pos in iter {
2427 inv.add(pos)?;
2428 }
2429 Ok(inv)
2430 }
2431}
2432
2433#[cfg(test)]
2434mod tests {
2435
2436 /// A deserialized inventory must not be reported as having headroom it
2437 /// does not have.
2438 ///
2439 /// The caches are `#[serde(skip)]`, so a round-trip once left `positions`
2440 /// populated and both caches empty. Deserialization now rebuilds them, so
2441 /// this passes because the cache is CORRECT rather than because
2442 /// `add_headroom_for` refuses to read an empty one. Both are checked: the
2443 /// defensive refusal stays as the second line of defense for any other way
2444 /// an inventory might reach that state (review catch on #1898).
2445 /// `new_shared` must actually produce the shared backing, and a serde
2446 /// round-trip must land back in `Owned`.
2447 ///
2448 /// Both are load-bearing and neither is visible from the public API: the
2449 /// backing is a private enum, so nothing outside this module can observe
2450 /// which one an inventory holds. Without this test, `new_shared` could
2451 /// quietly return the contiguous backing and the only symptom would be
2452 /// BQL's JOURNAL memory going from 31 MB back to 395 MB on a large
2453 /// ledger — a regression no unit test would catch. Copilot's catch on
2454 /// #2056.
2455 #[test]
2456 fn new_shared_is_shared_and_a_round_trip_is_owned() {
2457 let mut shared = Inventory::new_shared();
2458 assert!(
2459 matches!(shared.positions, PositionStore::Shared(_)),
2460 "new_shared must use the structurally-shared backing",
2461 );
2462
2463 // Adding must not silently convert it — the per-row snapshot in BQL
2464 // adds to this inventory between every clone.
2465 shared
2466 .add(Position::simple(Amount::new(dec!(5), "USD")))
2467 .expect("fits");
2468 assert!(
2469 matches!(shared.positions, PositionStore::Shared(_)),
2470 "add must keep the shared backing; converting here would restore \
2471 the O(rows x lots) blow-up #1086 is about",
2472 );
2473
2474 // ...but a reduction does convert, deliberately: it mutates heavily
2475 // and wants contiguous storage.
2476 let mut reduced = Inventory::new_shared();
2477 reduced
2478 .add(Position::simple(Amount::new(dec!(5), "USD")))
2479 .expect("fits");
2480 let _ = reduced.reduce(&Amount::new(dec!(-2), "USD"), None, BookingMethod::None);
2481 assert!(
2482 matches!(reduced.positions, PositionStore::Owned(_)),
2483 "reduce must switch to the contiguous backing",
2484 );
2485
2486 // The default constructor is contiguous.
2487 assert!(matches!(
2488 Inventory::new().positions,
2489 PositionStore::Owned(_)
2490 ));
2491
2492 // Serde carries a plain sequence and lands in `Owned`.
2493 let json = serde_json::to_string(&shared).expect("serializes");
2494 let back: Inventory = serde_json::from_str(&json).expect("deserializes");
2495 assert!(
2496 matches!(back.positions, PositionStore::Owned(_)),
2497 "a round-trip lands in the contiguous backing",
2498 );
2499 assert_eq!(back.units("USD"), dec!(5), "and preserves the positions");
2500 }
2501
2502 #[test]
2503 fn a_deserialized_inventory_refuses_to_claim_headroom() {
2504 let mut inv = Inventory::new();
2505 inv.add(Position::simple(Amount::new(Decimal::MAX, "USD")))
2506 .expect("one MAX position fits");
2507 assert!(!inv.add_headroom_for("USD", Decimal::ONE));
2508
2509 let round_tripped: Inventory =
2510 serde_json::from_str(&serde_json::to_string(&inv).expect("serialize"))
2511 .expect("deserialize");
2512
2513 assert!(
2514 !round_tripped.positions.is_empty(),
2515 "the positions survive the round-trip"
2516 );
2517 assert!(
2518 !round_tripped.units_cache.is_empty(),
2519 "and so do the caches now — deserialization rebuilds them"
2520 );
2521
2522 assert!(
2523 !round_tripped.add_headroom_for("USD", Decimal::ONE),
2524 "the inventory still holds Decimal::MAX"
2525 );
2526 }
2527
2528 /// A payload the type could not have produced must not panic us.
2529 ///
2530 /// Two cost-less lots for one currency violate the invariant
2531 /// `rebuild_index` asserts. That assert is a worthwhile internal-bug
2532 /// tripwire, but rebuilding on deserialization put it in reach of INPUT:
2533 /// this exact document panicked a debug build with "Invariant violated:
2534 /// multiple simple positions for currency USD". Caught reviewing the
2535 /// rebuild change, not present before it.
2536 ///
2537 /// Behavior matches what the plain derive did — the total is the sum, the
2538 /// lots are preserved — so nothing about malformed input changed except
2539 /// that the caches are now correct for it.
2540 #[test]
2541 fn a_payload_violating_the_lot_invariant_does_not_panic() {
2542 let json = r#"{"positions":[
2543 {"units":{"number":"100","currency":"USD"},"cost":null},
2544 {"units":{"number":"5","currency":"USD"},"cost":null}]}"#;
2545 let inv: Inventory = serde_json::from_str(json).expect("malformed input still loads");
2546 assert_eq!(inv.units("USD"), dec!(105), "the total sums every lot");
2547 assert_eq!(
2548 inv.positions().count(),
2549 2,
2550 "the lots are preserved as given"
2551 );
2552 }
2553
2554 /// A payload whose positions sum past the `Decimal` range is an ERROR,
2555 /// not a panic.
2556 ///
2557 /// Rebuilding the caches sums each currency's positions, and the rebuild
2558 /// used `+=`, which panics on `Decimal` overflow. Running it on
2559 /// deserialization put that inside `Deserialize`: two `Decimal::MAX` USD
2560 /// lots aborted with "Addition overflowed" instead of returning a serde
2561 /// error — a denial of service on any embedder deserializing untrusted input. Review
2562 /// catch on the rebuild change; the deep review that found the
2563 /// `debug_assert` panic missed this second one.
2564 ///
2565 /// Two lots are needed, and the first must carry a cost: a second cost-less
2566 /// lot for the same currency would be a different (also-tested) malformed
2567 /// shape, and the sum is what is being exercised here.
2568 #[test]
2569 fn a_payload_that_overflows_the_total_is_an_error_not_a_panic() {
2570 let max = Decimal::MAX.to_string();
2571 let json = format!(
2572 r#"{{"positions":[
2573 {{"units":{{"number":"{max}","currency":"USD"}},
2574 "cost":{{"number":"1","currency":"EUR","date":null,"label":null}}}},
2575 {{"units":{{"number":"{max}","currency":"USD"}},"cost":null}}]}}"#
2576 );
2577 let err = serde_json::from_str::<Inventory>(&json)
2578 .expect_err("a total past the Decimal range cannot be represented");
2579 // `OverflowError`'s own wording, which serde surfaces verbatim — so
2580 // this also pins that the error reaching the caller is the domain one
2581 // rather than a generic "invalid value".
2582 assert!(
2583 err.to_string().contains("exceeds the representable range"),
2584 "expected the USD overflow error, got: {err}",
2585 );
2586 }
2587
2588 /// `positions` stays REQUIRED.
2589 ///
2590 /// The derive this replaced made it so, and routing deserialization through
2591 /// a wire struct is exactly the kind of change that silently relaxes it —
2592 /// a stray `#[serde(default)]` turns a malformed document into an empty
2593 /// inventory. It did, in the first draft of this change.
2594 #[test]
2595 fn a_payload_without_positions_is_rejected() {
2596 let err = serde_json::from_str::<Inventory>("{}")
2597 .expect_err("an inventory without positions is malformed");
2598 assert!(
2599 err.to_string().contains("missing field"),
2600 "expected a missing-field error, got: {err}",
2601 );
2602 }
2603
2604 /// Mutating a deserialized inventory must not corrupt it.
2605 ///
2606 /// This is the case the rebuild exists for. `add` trusts both caches: it
2607 /// reads `units_cache.get(..).unwrap_or_default()` as the running total and
2608 /// `simple_index` as the lot to merge into. With both empty it read 0 for an
2609 /// inventory already holding 100 USD, wrote that back as the new total, and
2610 /// appended a second cost-less USD lot instead of merging — so a round-tripped
2611 /// 100 USD inventory answered `units("USD") == 5` after adding 5, holding two
2612 /// lots where the type's own invariant allows one.
2613 ///
2614 /// `units()` and `add_headroom_for` both survived that state on their own —
2615 /// one recomputes, the other refuses — which is exactly why it went
2616 /// unnoticed: the read paths were guarded and the WRITE path was not.
2617 #[test]
2618 fn adding_to_a_deserialized_inventory_keeps_the_running_total() {
2619 let mut inv = Inventory::new();
2620 inv.add(Position::simple(Amount::new(dec!(100), "USD")))
2621 .expect("fits");
2622
2623 let mut round_tripped: Inventory =
2624 serde_json::from_str(&serde_json::to_string(&inv).expect("serialize"))
2625 .expect("deserialize");
2626 assert_eq!(
2627 round_tripped.units("USD"),
2628 dec!(100),
2629 "the round-trip preserves the total"
2630 );
2631
2632 round_tripped
2633 .add(Position::simple(Amount::new(dec!(5), "USD")))
2634 .expect("fits");
2635
2636 assert_eq!(
2637 round_tripped.units("USD"),
2638 dec!(105),
2639 "add must extend the existing total, not replace it"
2640 );
2641 assert_eq!(
2642 round_tripped.positions().count(),
2643 1,
2644 "a cost-less add merges into the existing lot rather than appending"
2645 );
2646 }
2647
2648 /// `add_headroom_for` treats `needed` as a magnitude, whatever sign it
2649 /// arrives with.
2650 ///
2651 /// A negative `needed` would make the internal sums smaller and return
2652 /// `true` where overflow is possible. `apply` would then skip the snapshot
2653 /// it needed, leaving a failing transaction's earlier postings applied —
2654 /// silent corruption. Not reachable from the in-tree caller, which sums
2655 /// absolute values, but this is a `pub` method (review catch on #1898).
2656 #[test]
2657 fn add_headroom_for_reads_needed_as_a_magnitude() {
2658 let mut inv = Inventory::new();
2659 inv.add(Position::simple(Amount::new(Decimal::MAX, "USD")))
2660 .expect("one MAX position fits");
2661
2662 assert!(
2663 !inv.add_headroom_for("USD", Decimal::ONE),
2664 "at the ceiling, there is no room for one more unit"
2665 );
2666 assert!(
2667 !inv.add_headroom_for("USD", -Decimal::ONE),
2668 "and a negatively-signed magnitude must not manufacture room"
2669 );
2670 assert_eq!(
2671 inv.add_headroom_for("USD", Decimal::ONE),
2672 inv.add_headroom_for("USD", -Decimal::ONE),
2673 "the sign of `needed` cannot change the answer"
2674 );
2675 }
2676
2677 use super::*;
2678 use crate::Cost;
2679 use crate::NaiveDate;
2680 use rust_decimal_macros::dec;
2681
2682 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
2683 crate::naive_date(year, month, day).unwrap()
2684 }
2685
2686 #[test]
2687 fn test_empty_inventory() {
2688 let inv = Inventory::new();
2689 assert!(inv.is_empty());
2690 assert_eq!(inv.len(), 0);
2691 }
2692
2693 #[test]
2694 fn test_add_simple() {
2695 let mut inv = Inventory::new();
2696 inv.add(Position::simple(Amount::new(dec!(100), "USD")))
2697 .expect("fixture fits in Decimal");
2698
2699 assert!(!inv.is_empty());
2700 assert_eq!(inv.units("USD"), dec!(100));
2701 }
2702
2703 #[test]
2704 fn test_add_merge_simple() {
2705 let mut inv = Inventory::new();
2706 inv.add(Position::simple(Amount::new(dec!(100), "USD")))
2707 .expect("fixture fits in Decimal");
2708 inv.add(Position::simple(Amount::new(dec!(50), "USD")))
2709 .expect("fixture fits in Decimal");
2710
2711 // Should merge into one position
2712 assert_eq!(inv.len(), 1);
2713 assert_eq!(inv.units("USD"), dec!(150));
2714 }
2715
2716 #[test]
2717 fn test_add_with_cost_no_merge() {
2718 let mut inv = Inventory::new();
2719
2720 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
2721 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
2722
2723 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2724 .expect("fixture fits in Decimal");
2725 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
2726 .expect("fixture fits in Decimal");
2727
2728 // Should NOT merge - different costs
2729 assert_eq!(inv.len(), 2);
2730 assert_eq!(inv.units("AAPL"), dec!(15));
2731 }
2732
2733 #[test]
2734 fn test_currencies() {
2735 let mut inv = Inventory::new();
2736 inv.add(Position::simple(Amount::new(dec!(100), "USD")))
2737 .expect("fixture fits in Decimal");
2738 inv.add(Position::simple(Amount::new(dec!(50), "EUR")))
2739 .expect("fixture fits in Decimal");
2740 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")))
2741 .expect("fixture fits in Decimal");
2742
2743 let currencies = inv.currencies();
2744 assert_eq!(currencies.len(), 3);
2745 assert!(currencies.contains(&"USD"));
2746 assert!(currencies.contains(&"EUR"));
2747 assert!(currencies.contains(&"AAPL"));
2748 }
2749
2750 #[test]
2751 fn test_reduce_strict_unique() {
2752 let mut inv = Inventory::new();
2753 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
2754 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
2755 .expect("fixture fits in Decimal");
2756
2757 let result = inv
2758 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
2759 .unwrap();
2760
2761 assert_eq!(inv.units("AAPL"), dec!(5));
2762 assert!(result.cost_basis.is_some());
2763 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); // 5 * 150
2764 }
2765
2766 #[test]
2767 fn test_reduce_strict_multiple_match_with_different_costs_is_ambiguous() {
2768 let mut inv = Inventory::new();
2769
2770 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
2771 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
2772
2773 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2774 .expect("fixture fits in Decimal");
2775 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
2776 .expect("fixture fits in Decimal");
2777
2778 // Per Python beancount: a wildcard reduction (`-3 AAPL` with no cost
2779 // spec) against an inventory with lots at different costs is
2780 // genuinely ambiguous and must error. Issue #737.
2781 let result = inv.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict);
2782
2783 assert!(
2784 matches!(result, Err(BookingError::AmbiguousMatch { .. })),
2785 "expected AmbiguousMatch, got {result:?}"
2786 );
2787 // Inventory unchanged after a failed reduction
2788 assert_eq!(inv.units("AAPL"), dec!(15));
2789 }
2790
2791 #[test]
2792 fn test_reduce_strict_multiple_match_with_identical_costs_uses_fifo() {
2793 let mut inv = Inventory::new();
2794
2795 // Two lots with identical cost — interchangeable, so FIFO is fine.
2796 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
2797
2798 inv.add(Position::with_cost(
2799 Amount::new(dec!(10), "AAPL"),
2800 cost.clone(),
2801 ))
2802 .expect("fixture fits in Decimal");
2803 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost))
2804 .expect("fixture fits in Decimal");
2805
2806 let result = inv
2807 .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
2808 .expect("identical lots should fall back to FIFO without error");
2809
2810 assert_eq!(inv.units("AAPL"), dec!(12));
2811 assert_eq!(result.cost_basis.unwrap().number, dec!(450.00));
2812 }
2813
2814 #[test]
2815 fn test_reduce_strict_same_cost_different_dates_is_ambiguous() {
2816 // #2097. Two lots at the same cost number, differing only in
2817 // acquisition date. This used to drain them FIFO on the grounds that
2818 // the lots were interchangeable. They are not: whichever survives
2819 // carries its own date, and holding period drives the short/long
2820 // split in `report capgains` and per-lot IRR eligibility. Beancount
2821 // rejects it too — `booking_method_STRICT` has no fallback.
2822 let mut inv = Inventory::new();
2823
2824 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
2825 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 15));
2826
2827 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2828 .expect("fixture fits in Decimal");
2829 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
2830 .expect("fixture fits in Decimal");
2831
2832 let err = inv
2833 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
2834 .expect_err("a partial sale cannot choose between two dated lots");
2835 assert!(
2836 matches!(err, BookingError::AmbiguousMatch { num_matches: 2, .. }),
2837 "expected AmbiguousMatch over the two dated lots, got {err:?}"
2838 );
2839
2840 // And it left the inventory alone.
2841 assert_eq!(inv.units("AAPL"), dec!(20));
2842 }
2843
2844 #[test]
2845 fn test_reduce_strict_selling_every_matched_lot_is_not_ambiguous() {
2846 // The total-match exception, which beancount has too: consume every
2847 // matched lot and no lot survives to carry a date, so the choice
2848 // cannot be observed.
2849 let mut inv = Inventory::new();
2850
2851 inv.add(Position::with_cost(
2852 Amount::new(dec!(10), "AAPL"),
2853 Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15)),
2854 ))
2855 .expect("fixture fits in Decimal");
2856 inv.add(Position::with_cost(
2857 Amount::new(dec!(10), "AAPL"),
2858 Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 15)),
2859 ))
2860 .expect("fixture fits in Decimal");
2861
2862 let result = inv
2863 .reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Strict)
2864 .expect("selling the whole matched set names no lot to choose");
2865 assert_eq!(inv.units("AAPL"), dec!(0));
2866 assert_eq!(result.cost_basis.unwrap().number, dec!(3000.00));
2867 }
2868
2869 #[test]
2870 fn test_reduce_strict_multiple_match_total_match_exception() {
2871 let mut inv = Inventory::new();
2872
2873 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
2874 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
2875
2876 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2877 .expect("fixture fits in Decimal");
2878 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
2879 .expect("fixture fits in Decimal");
2880
2881 // Selling exactly the entire inventory (10 + 5 = 15) is unambiguous
2882 // even with mixed costs — the user is liquidating the position.
2883 let result = inv
2884 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Strict)
2885 .expect("total-match exception should accept a full liquidation");
2886
2887 assert_eq!(inv.units("AAPL"), dec!(0));
2888 // Cost basis = 10*150 + 5*160 = 1500 + 800 = 2300
2889 assert_eq!(result.cost_basis.unwrap().number, dec!(2300.00));
2890 }
2891
2892 #[test]
2893 fn test_reduce_strict_with_spec() {
2894 let mut inv = Inventory::new();
2895
2896 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
2897 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
2898
2899 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2900 .expect("fixture fits in Decimal");
2901 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
2902 .expect("fixture fits in Decimal");
2903
2904 // Reducing with cost spec should work
2905 let spec = CostSpec::empty().with_date(date(2024, 1, 1));
2906 let result = inv
2907 .reduce(
2908 &Amount::new(dec!(-3), "AAPL"),
2909 Some(&spec),
2910 BookingMethod::Strict,
2911 )
2912 .unwrap();
2913
2914 assert_eq!(inv.units("AAPL"), dec!(12)); // 7 + 5
2915 assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); // 3 * 150
2916 }
2917
2918 #[test]
2919 fn test_reduce_fifo() {
2920 let mut inv = Inventory::new();
2921
2922 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2923 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
2924 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2925
2926 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2927 .expect("fixture fits in Decimal");
2928 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
2929 .expect("fixture fits in Decimal");
2930 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3))
2931 .expect("fixture fits in Decimal");
2932
2933 // FIFO should reduce from oldest (cost 100) first
2934 let result = inv
2935 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
2936 .unwrap();
2937
2938 assert_eq!(inv.units("AAPL"), dec!(15));
2939 // Cost basis: 10 * 100 + 5 * 150 = 1000 + 750 = 1750
2940 assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
2941 }
2942
2943 #[test]
2944 fn test_reduce_lifo() {
2945 let mut inv = Inventory::new();
2946
2947 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2948 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
2949 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2950
2951 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2952 .expect("fixture fits in Decimal");
2953 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
2954 .expect("fixture fits in Decimal");
2955 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3))
2956 .expect("fixture fits in Decimal");
2957
2958 // LIFO should reduce from newest (cost 200) first
2959 let result = inv
2960 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
2961 .unwrap();
2962
2963 assert_eq!(inv.units("AAPL"), dec!(15));
2964 // Cost basis: 10 * 200 + 5 * 150 = 2000 + 750 = 2750
2965 assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
2966 }
2967
2968 #[test]
2969 fn test_reduce_insufficient() {
2970 let mut inv = Inventory::new();
2971 let cost = Cost::new(dec!(150.00), "USD");
2972 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
2973 .expect("fixture fits in Decimal");
2974
2975 let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
2976
2977 assert!(matches!(
2978 result,
2979 Err(BookingError::InsufficientUnits { .. })
2980 ));
2981 }
2982
2983 #[test]
2984 fn test_book_value() {
2985 let mut inv = Inventory::new();
2986
2987 let cost1 = Cost::new(dec!(100.00), "USD");
2988 let cost2 = Cost::new(dec!(150.00), "USD");
2989
2990 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2991 .expect("fixture fits in Decimal");
2992 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
2993 .expect("fixture fits in Decimal");
2994
2995 let book = inv.book_value("AAPL").expect("fixture fits in Decimal");
2996 assert_eq!(book.get("USD"), Some(&dec!(1750.00))); // 10*100 + 5*150
2997 }
2998
2999 #[test]
3000 fn test_display() {
3001 let mut inv = Inventory::new();
3002 inv.add(Position::simple(Amount::new(dec!(100), "USD")))
3003 .expect("fixture fits in Decimal");
3004
3005 let s = format!("{inv}");
3006 assert!(s.contains("100 USD"));
3007 }
3008
3009 #[test]
3010 fn test_display_empty() {
3011 let inv = Inventory::new();
3012 assert_eq!(format!("{inv}"), "(empty)");
3013 }
3014
3015 #[test]
3016 fn test_from_iterator() {
3017 let positions = vec![
3018 Position::simple(Amount::new(dec!(100), "USD")),
3019 Position::simple(Amount::new(dec!(50), "USD")),
3020 ];
3021
3022 let inv = Inventory::try_from_positions(positions).expect("fixture fits in Decimal");
3023 assert_eq!(inv.units("USD"), dec!(150));
3024 }
3025
3026 #[test]
3027 fn test_add_costed_positions_kept_separate() {
3028 // Costed positions are kept as separate lots for O(1) add performance.
3029 // Aggregation happens at display time (in query output).
3030 let mut inv = Inventory::new();
3031
3032 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
3033
3034 // Buy 10 shares
3035 inv.add(Position::with_cost(
3036 Amount::new(dec!(10), "AAPL"),
3037 cost.clone(),
3038 ))
3039 .expect("fixture fits in Decimal");
3040 assert_eq!(inv.len(), 1);
3041 assert_eq!(inv.units("AAPL"), dec!(10));
3042
3043 // Sell 10 shares - kept as separate lot for tracking
3044 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost))
3045 .expect("fixture fits in Decimal");
3046 assert_eq!(inv.len(), 2); // Both lots kept
3047 assert_eq!(inv.units("AAPL"), dec!(0)); // Net units still zero
3048 }
3049
3050 /// A deserialized inventory must report the same scale as one built by
3051 /// incremental `add`s.
3052 ///
3053 /// `units_cache` is `#[serde(skip)]`, so `try_rebuild_index_from` is what
3054 /// reconstructs it after a round-trip. That rebuild re-sums the positions;
3055 /// if it used a raw `checked_add` while `add` used the Python scale rule,
3056 /// the two would part company the moment the running sum crossed zero —
3057 /// the same money reporting `1.00` from one path and `1` from the other,
3058 /// silently, depending only on whether it had been serialized.
3059 ///
3060 /// Needs COST-BEARING lots. Cost-less positions coalesce into a single
3061 /// position, and one position cannot cross zero during the rebuild, so a
3062 /// simpler fixture passes either way and pins nothing.
3063 #[test]
3064 fn a_round_trip_reports_the_same_scale_as_incremental_adds() {
3065 let mut inv = Inventory::new();
3066 let lots = [
3067 (
3068 dec!(2.00),
3069 Cost::new(dec!(10.00), "USD").with_date(date(2024, 1, 1)),
3070 ),
3071 (
3072 dec!(-2.00),
3073 Cost::new(dec!(11.00), "USD").with_date(date(2024, 1, 2)),
3074 ),
3075 (
3076 dec!(1),
3077 Cost::new(dec!(12.00), "USD").with_date(date(2024, 1, 3)),
3078 ),
3079 ];
3080 for (units, cost) in lots {
3081 inv.add(Position::with_cost(Amount::new(units, "SH"), cost))
3082 .expect("fixture fits in Decimal");
3083 }
3084
3085 let built = inv.units("SH").to_string();
3086 assert_eq!(built, "1.00", "the incrementally-built total");
3087
3088 let json = serde_json::to_string(&inv).expect("serializes");
3089 let round_tripped: Inventory = serde_json::from_str(&json).expect("deserializes");
3090 assert_eq!(
3091 round_tripped.units("SH").to_string(),
3092 built,
3093 "a serde round-trip must not change the reported scale",
3094 );
3095 }
3096
3097 /// Coalescing must not make a balance depend on the order it was built in.
3098 ///
3099 /// `rust_decimal` returns the other operand untouched when one side is
3100 /// zero, so a running total that passes through zero loses its scale and
3101 /// every later addend renders one scale narrower. The two inventories
3102 /// below hold the SAME multiset of amounts in a different order.
3103 ///
3104 /// Asserts on `to_string()`, not on `Decimal` equality: `==` compares
3105 /// value and ignores scale (`dec!(1) == dec!(1.00)`), so a value-level
3106 /// assertion here would pass against the bug it is pinning.
3107 #[test]
3108 fn coalescing_is_independent_of_the_order_amounts_arrive_in() {
3109 // Passes through 0.00 (scale 2), then takes a scale-0 addend.
3110 let zero_crossing_first = [dec!(-2.00), dec!(2.00), dec!(-1)];
3111 // Same amounts, no zero crossing before the scale-0 addend.
3112 let zero_crossing_last = [dec!(-1), dec!(-2.00), dec!(2.00)];
3113
3114 let build = |amounts: &[Decimal]| {
3115 let mut inv = Inventory::new();
3116 for n in amounts {
3117 inv.add(Position::simple(Amount::new(*n, "USD")))
3118 .expect("fixture fits in Decimal");
3119 }
3120 inv
3121 };
3122
3123 let a = build(&zero_crossing_first);
3124 let b = build(&zero_crossing_last);
3125
3126 // The merged POSITION.
3127 assert_eq!(
3128 a.positions()
3129 .next()
3130 .expect("one position")
3131 .units
3132 .number
3133 .to_string(),
3134 "-1.00",
3135 "a total that passed through zero must keep the widest scale",
3136 );
3137 assert_eq!(
3138 b.positions()
3139 .next()
3140 .expect("one position")
3141 .units
3142 .number
3143 .to_string(),
3144 "-1.00",
3145 );
3146
3147 // And the units CACHE, which is maintained separately and would
3148 // otherwise disagree with the position it summarizes.
3149 assert_eq!(a.units("USD").to_string(), "-1.00");
3150 assert_eq!(b.units("USD").to_string(), "-1.00");
3151 }
3152
3153 #[test]
3154 fn test_add_costed_positions_net_units() {
3155 // Verify that units() correctly sums across all lots
3156 let mut inv = Inventory::new();
3157
3158 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
3159
3160 // Buy 10 shares
3161 inv.add(Position::with_cost(
3162 Amount::new(dec!(10), "AAPL"),
3163 cost.clone(),
3164 ))
3165 .expect("fixture fits in Decimal");
3166
3167 // Sell 3 shares - kept as separate lot
3168 inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost))
3169 .expect("fixture fits in Decimal");
3170 assert_eq!(inv.len(), 2); // Both lots kept
3171 assert_eq!(inv.units("AAPL"), dec!(7)); // Net units correct
3172 }
3173
3174 #[test]
3175 fn test_add_no_cancel_different_cost() {
3176 // Test that different costs don't cancel
3177 let mut inv = Inventory::new();
3178
3179 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
3180 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
3181
3182 // Buy 10 shares at 150
3183 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3184 .expect("fixture fits in Decimal");
3185
3186 // Sell 5 shares at 160 - should NOT cancel (different cost)
3187 inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2))
3188 .expect("fixture fits in Decimal");
3189
3190 // Should have two separate lots
3191 assert_eq!(inv.len(), 2);
3192 assert_eq!(inv.units("AAPL"), dec!(5)); // 10 - 5 = 5 total
3193 }
3194
3195 #[test]
3196 fn test_add_no_cancel_same_sign() {
3197 // Test that same-sign positions don't merge even with same cost
3198 let mut inv = Inventory::new();
3199
3200 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
3201
3202 // Buy 10 shares
3203 inv.add(Position::with_cost(
3204 Amount::new(dec!(10), "AAPL"),
3205 cost.clone(),
3206 ))
3207 .expect("fixture fits in Decimal");
3208
3209 // Buy 5 more shares with same cost - should NOT merge
3210 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost))
3211 .expect("fixture fits in Decimal");
3212
3213 // Should have two separate lots (different acquisitions)
3214 assert_eq!(inv.len(), 2);
3215 assert_eq!(inv.units("AAPL"), dec!(15));
3216 }
3217
3218 #[test]
3219 fn test_merge_keeps_lots_separate() {
3220 // Test that merge keeps costed lots separate (aggregation at display time)
3221 let mut inv1 = Inventory::new();
3222 let mut inv2 = Inventory::new();
3223
3224 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
3225
3226 // inv1: buy 10 shares
3227 inv1.add(Position::with_cost(
3228 Amount::new(dec!(10), "AAPL"),
3229 cost.clone(),
3230 ))
3231 .expect("fixture fits in Decimal");
3232
3233 // inv2: sell 10 shares
3234 inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost))
3235 .expect("fixture fits in Decimal");
3236
3237 // Merge keeps both lots, net units is zero
3238 inv1.merge(&inv2).expect("fixture fits in Decimal");
3239 assert_eq!(inv1.len(), 2); // Both lots preserved
3240 assert_eq!(inv1.units("AAPL"), dec!(0)); // Net units correct
3241 }
3242
3243 // ====================================================================
3244 // Phase 2: Additional Coverage Tests for Booking Methods
3245 // ====================================================================
3246
3247 #[test]
3248 fn test_hifo_with_tie_breaking() {
3249 // When multiple lots have the same cost, HIFO should use insertion order
3250 let mut inv = Inventory::new();
3251
3252 // Three lots with same cost but different dates
3253 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3254 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
3255 let cost3 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 3, 1));
3256
3257 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3258 .expect("fixture fits in Decimal");
3259 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
3260 .expect("fixture fits in Decimal");
3261 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3))
3262 .expect("fixture fits in Decimal");
3263
3264 // HIFO with tied costs should reduce in some deterministic order
3265 let result = inv
3266 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
3267 .unwrap();
3268
3269 assert_eq!(inv.units("AAPL"), dec!(15));
3270 // All at same cost, so 15 * 100 = 1500
3271 assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
3272 }
3273
3274 #[test]
3275 fn test_hifo_with_different_costs() {
3276 // HIFO should reduce highest cost lots first
3277 let mut inv = Inventory::new();
3278
3279 let cost_low = Cost::new(dec!(50.00), "USD").with_date(date(2024, 1, 1));
3280 let cost_mid = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
3281 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
3282
3283 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low))
3284 .expect("fixture fits in Decimal");
3285 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid))
3286 .expect("fixture fits in Decimal");
3287 inv.add(Position::with_cost(
3288 Amount::new(dec!(10), "AAPL"),
3289 cost_high,
3290 ))
3291 .expect("fixture fits in Decimal");
3292
3293 // Reduce 15 shares - should take from highest cost (200) first
3294 let result = inv
3295 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
3296 .unwrap();
3297
3298 assert_eq!(inv.units("AAPL"), dec!(15));
3299 // 10 * 200 + 5 * 100 = 2000 + 500 = 2500
3300 assert_eq!(result.cost_basis.unwrap().number, dec!(2500.00));
3301 }
3302
3303 #[test]
3304 fn test_average_booking_with_pre_existing_positions() {
3305 let mut inv = Inventory::new();
3306
3307 // Add two lots with different costs
3308 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3309 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
3310
3311 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3312 .expect("fixture fits in Decimal");
3313 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
3314 .expect("fixture fits in Decimal");
3315
3316 // Total: 20 shares, total cost = 10*100 + 10*200 = 3000, avg = 150/share
3317 // Reduce 5 shares using AVERAGE
3318 let result = inv
3319 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
3320 .unwrap();
3321
3322 assert_eq!(inv.units("AAPL"), dec!(15));
3323 // Cost basis for 5 shares at average 150 = 750
3324 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
3325 }
3326
3327 #[test]
3328 fn test_average_booking_reduces_all() {
3329 let mut inv = Inventory::new();
3330
3331 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3332 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
3333 .expect("fixture fits in Decimal");
3334
3335 // Reduce all shares
3336 let result = inv
3337 .reduce(
3338 &Amount::new(dec!(-10), "AAPL"),
3339 None,
3340 BookingMethod::Average,
3341 )
3342 .unwrap();
3343
3344 assert!(inv.is_empty() || inv.units("AAPL").is_zero());
3345 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
3346 }
3347
3348 #[test]
3349 fn test_none_booking_augmentation() {
3350 // NONE booking with same-sign amounts should augment, not reduce
3351 let mut inv = Inventory::new();
3352 inv.add(Position::simple(Amount::new(dec!(100), "USD")))
3353 .expect("fixture fits in Decimal");
3354
3355 // Adding more (same sign) - this is an augmentation
3356 let result = inv
3357 .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
3358 .unwrap();
3359
3360 assert_eq!(inv.units("USD"), dec!(150));
3361 assert!(result.matched.is_empty()); // No lots matched for augmentation
3362 assert!(result.cost_basis.is_none());
3363 }
3364
3365 #[test]
3366 fn test_none_booking_reduction() {
3367 // NONE booking with opposite-sign should reduce
3368 let mut inv = Inventory::new();
3369 inv.add(Position::simple(Amount::new(dec!(100), "USD")))
3370 .expect("fixture fits in Decimal");
3371
3372 let result = inv
3373 .reduce(&Amount::new(dec!(-30), "USD"), None, BookingMethod::None)
3374 .unwrap();
3375
3376 assert_eq!(inv.units("USD"), dec!(70));
3377 assert!(!result.matched.is_empty());
3378 }
3379
3380 #[test]
3381 fn test_none_booking_shorts_past_zero() {
3382 let mut inv = Inventory::new();
3383 inv.add(Position::simple(Amount::new(dec!(100), "USD")))
3384 .expect("fixture fits in Decimal");
3385
3386 // NONE performs no booking: reducing past the balance shorts instead
3387 // of erroring (#1686 — previously InsufficientUnits, inconsistent
3388 // with the zero-balance case, NONECorrect.tla, and beancount NONE).
3389 let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
3390
3391 assert!(result.is_ok(), "NONE must allow shorting: {result:?}");
3392 assert_eq!(inv.units("USD"), dec!(-50));
3393 }
3394
3395 #[test]
3396 fn test_booking_error_no_matching_lot() {
3397 let mut inv = Inventory::new();
3398
3399 // Add a lot with specific cost
3400 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3401 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
3402 .expect("fixture fits in Decimal");
3403
3404 // Try to reduce with a cost spec that doesn't match
3405 let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
3406 let result = inv.reduce(
3407 &Amount::new(dec!(-5), "AAPL"),
3408 Some(&wrong_spec),
3409 BookingMethod::Strict,
3410 );
3411
3412 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
3413 }
3414
3415 #[test]
3416 fn test_booking_error_insufficient_units() {
3417 let mut inv = Inventory::new();
3418
3419 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3420 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
3421 .expect("fixture fits in Decimal");
3422
3423 // Try to reduce more than available
3424 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
3425
3426 match result {
3427 Err(BookingError::InsufficientUnits {
3428 requested,
3429 available,
3430 ..
3431 }) => {
3432 assert_eq!(requested, dec!(20));
3433 assert_eq!(available, dec!(10));
3434 }
3435 _ => panic!("Expected InsufficientUnits error"),
3436 }
3437 }
3438
3439 #[test]
3440 fn test_strict_with_size_exact_match() {
3441 let mut inv = Inventory::new();
3442
3443 // Add two lots with same cost but different sizes
3444 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3445 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
3446
3447 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3448 .expect("fixture fits in Decimal");
3449 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
3450 .expect("fixture fits in Decimal");
3451
3452 // Reduce exactly 5 - should match the 5-share lot
3453 let result = inv
3454 .reduce(
3455 &Amount::new(dec!(-5), "AAPL"),
3456 None,
3457 BookingMethod::StrictWithSize,
3458 )
3459 .unwrap();
3460
3461 assert_eq!(inv.units("AAPL"), dec!(10));
3462 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
3463 }
3464
3465 #[test]
3466 fn test_strict_with_size_total_match() {
3467 let mut inv = Inventory::new();
3468
3469 // Add two lots
3470 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3471 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
3472
3473 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3474 .expect("fixture fits in Decimal");
3475 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
3476 .expect("fixture fits in Decimal");
3477
3478 // Reduce exactly 15 (total) - should succeed via total match exception
3479 let result = inv
3480 .reduce(
3481 &Amount::new(dec!(-15), "AAPL"),
3482 None,
3483 BookingMethod::StrictWithSize,
3484 )
3485 .unwrap();
3486
3487 assert_eq!(inv.units("AAPL"), dec!(0));
3488 assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
3489 }
3490
3491 #[test]
3492 fn test_strict_with_size_ambiguous() {
3493 let mut inv = Inventory::new();
3494
3495 // Add two lots of same size and cost
3496 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3497 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
3498
3499 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3500 .expect("fixture fits in Decimal");
3501 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
3502 .expect("fixture fits in Decimal");
3503
3504 // Reduce 7 shares - doesn't match either lot exactly, not total
3505 let result = inv.reduce(
3506 &Amount::new(dec!(-7), "AAPL"),
3507 None,
3508 BookingMethod::StrictWithSize,
3509 );
3510
3511 assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
3512 }
3513
3514 #[test]
3515 fn test_short_position() {
3516 // Test short selling (negative positions)
3517 let mut inv = Inventory::new();
3518
3519 // Short 10 shares
3520 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3521 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost))
3522 .expect("fixture fits in Decimal");
3523
3524 assert_eq!(inv.units("AAPL"), dec!(-10));
3525 assert!(!inv.is_empty());
3526 }
3527
3528 #[test]
3529 fn test_at_cost() {
3530 let mut inv = Inventory::new();
3531
3532 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3533 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
3534
3535 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3536 .expect("fixture fits in Decimal");
3537 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
3538 .expect("fixture fits in Decimal");
3539 inv.add(Position::simple(Amount::new(dec!(100), "USD")))
3540 .expect("fixture fits in Decimal");
3541
3542 let at_cost = inv.at_cost().expect("fixture fits in Decimal");
3543
3544 // AAPL converted: 10*100 + 5*150 = 1000 + 750 = 1750 USD
3545 // Plus 100 USD simple position = 1850 USD total
3546 assert_eq!(at_cost.units("USD"), dec!(1850));
3547 assert_eq!(at_cost.units("AAPL"), dec!(0)); // No AAPL in cost view
3548 }
3549
3550 #[test]
3551 fn test_at_units() {
3552 let mut inv = Inventory::new();
3553
3554 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3555 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
3556
3557 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3558 .expect("fixture fits in Decimal");
3559 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
3560 .expect("fixture fits in Decimal");
3561
3562 let at_units = inv.at_units().expect("fixture fits in Decimal");
3563
3564 // All AAPL lots merged
3565 assert_eq!(at_units.units("AAPL"), dec!(15));
3566 // Should only have one position after aggregation
3567 assert_eq!(at_units.len(), 1);
3568 }
3569
3570 #[test]
3571 fn test_add_empty_position() {
3572 let mut inv = Inventory::new();
3573 inv.add(Position::simple(Amount::new(dec!(0), "USD")))
3574 .expect("fixture fits in Decimal");
3575
3576 assert!(inv.is_empty());
3577 assert_eq!(inv.len(), 0);
3578 }
3579
3580 #[test]
3581 fn test_compact() {
3582 let mut inv = Inventory::new();
3583
3584 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3585 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
3586 .expect("fixture fits in Decimal");
3587
3588 // Reduce all
3589 inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
3590 .unwrap();
3591
3592 // Compact to remove empty positions
3593 inv.compact();
3594 assert!(inv.is_empty());
3595 assert_eq!(inv.len(), 0);
3596 }
3597
3598 #[test]
3599 fn test_booking_method_from_str() {
3600 assert_eq!(
3601 BookingMethod::from_str("STRICT").unwrap(),
3602 BookingMethod::Strict
3603 );
3604 assert_eq!(
3605 BookingMethod::from_str("fifo").unwrap(),
3606 BookingMethod::Fifo
3607 );
3608 assert_eq!(
3609 BookingMethod::from_str("LIFO").unwrap(),
3610 BookingMethod::Lifo
3611 );
3612 assert_eq!(
3613 BookingMethod::from_str("Hifo").unwrap(),
3614 BookingMethod::Hifo
3615 );
3616 assert_eq!(
3617 BookingMethod::from_str("AVERAGE").unwrap(),
3618 BookingMethod::Average
3619 );
3620 assert_eq!(
3621 BookingMethod::from_str("NONE").unwrap(),
3622 BookingMethod::None
3623 );
3624 assert_eq!(
3625 BookingMethod::from_str("strict_with_size").unwrap(),
3626 BookingMethod::StrictWithSize
3627 );
3628 assert!(BookingMethod::from_str("INVALID").is_err());
3629 }
3630
3631 #[test]
3632 fn test_booking_method_display() {
3633 assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
3634 assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
3635 assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
3636 assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
3637 assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
3638 assert_eq!(format!("{}", BookingMethod::None), "NONE");
3639 assert_eq!(
3640 format!("{}", BookingMethod::StrictWithSize),
3641 "STRICT_WITH_SIZE"
3642 );
3643 }
3644
3645 #[test]
3646 fn test_booking_error_display() {
3647 let err = BookingError::AmbiguousMatch {
3648 num_matches: 3,
3649 currency: "AAPL".into(),
3650 };
3651 assert!(format!("{err}").contains("3 lots match"));
3652
3653 let err = BookingError::NoMatchingLot {
3654 currency: "AAPL".into(),
3655 cost_spec: CostSpec::empty(),
3656 };
3657 assert!(format!("{err}").contains("No matching lot"));
3658
3659 let err = BookingError::InsufficientUnits {
3660 currency: "AAPL".into(),
3661 requested: dec!(100),
3662 available: dec!(50),
3663 };
3664 assert!(format!("{err}").contains("requested 100"));
3665 assert!(format!("{err}").contains("available 50"));
3666
3667 let err = BookingError::CurrencyMismatch {
3668 expected: "USD".into(),
3669 got: "EUR".into(),
3670 };
3671 assert!(format!("{err}").contains("expected USD"));
3672 assert!(format!("{err}").contains("got EUR"));
3673 }
3674
3675 #[test]
3676 fn test_book_value_multiple_currencies() {
3677 let mut inv = Inventory::new();
3678
3679 // Cost in USD
3680 let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3681 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd))
3682 .expect("fixture fits in Decimal");
3683
3684 // Cost in EUR
3685 let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
3686 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur))
3687 .expect("fixture fits in Decimal");
3688
3689 let book = inv.book_value("AAPL").expect("fixture fits in Decimal");
3690 assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
3691 assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
3692 }
3693
3694 #[test]
3695 fn test_reduce_hifo_insufficient_units() {
3696 let mut inv = Inventory::new();
3697
3698 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3699 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
3700 .expect("fixture fits in Decimal");
3701
3702 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
3703
3704 assert!(matches!(
3705 result,
3706 Err(BookingError::InsufficientUnits { .. })
3707 ));
3708 }
3709
3710 #[test]
3711 fn test_reduce_average_insufficient_units() {
3712 let mut inv = Inventory::new();
3713
3714 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3715 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
3716 .expect("fixture fits in Decimal");
3717
3718 let result = inv.reduce(
3719 &Amount::new(dec!(-20), "AAPL"),
3720 None,
3721 BookingMethod::Average,
3722 );
3723
3724 assert!(matches!(
3725 result,
3726 Err(BookingError::InsufficientUnits { .. })
3727 ));
3728 }
3729
3730 #[test]
3731 fn test_reduce_average_empty_inventory() {
3732 let mut inv = Inventory::new();
3733
3734 let result = inv.reduce(
3735 &Amount::new(dec!(-10), "AAPL"),
3736 None,
3737 BookingMethod::Average,
3738 );
3739
3740 assert!(matches!(
3741 result,
3742 Err(BookingError::InsufficientUnits { .. })
3743 ));
3744 }
3745
3746 #[test]
3747 fn test_reduce_merge_operator() {
3748 // {*} merge: two lots merged into weighted-average, then reduced
3749 let mut inv = Inventory::new();
3750 inv.add(Position::with_cost(
3751 Amount::new(dec!(10), "AAPL"),
3752 Cost::new(dec!(150), "USD"),
3753 ))
3754 .expect("fixture fits in Decimal");
3755 inv.add(Position::with_cost(
3756 Amount::new(dec!(10), "AAPL"),
3757 Cost::new(dec!(160), "USD"),
3758 ))
3759 .expect("fixture fits in Decimal");
3760
3761 let merge_spec = CostSpec::empty().with_merge();
3762 let result = inv
3763 .reduce(
3764 &Amount::new(dec!(-5), "AAPL"),
3765 Some(&merge_spec),
3766 BookingMethod::Strict,
3767 )
3768 .expect("merge reduction should succeed");
3769
3770 // Cost basis: 5 units * 155 USD average = 775 USD
3771 assert_eq!(result.cost_basis, Some(Amount::new(dec!(775), "USD")));
3772
3773 // Inventory should have a single merged lot with 15 remaining @ 155
3774 assert_eq!(inv.positions.len(), 1);
3775 // Through the iterator, not a raw slot: the merged lot is appended
3776 // after the tombstoned originals, and the iterator is what every
3777 // consumer sees.
3778 let merged = inv.positions().next().expect("one merged lot");
3779 assert_eq!(merged.units.number, dec!(15));
3780 let cost = merged.cost.as_ref().expect("should have cost");
3781 assert_eq!(cost.number, dec!(155));
3782 }
3783
3784 #[test]
3785 fn test_reduce_merge_insufficient_units() {
3786 let mut inv = Inventory::new();
3787 inv.add(Position::with_cost(
3788 Amount::new(dec!(10), "AAPL"),
3789 Cost::new(dec!(150), "USD"),
3790 ))
3791 .expect("fixture fits in Decimal");
3792
3793 let merge_spec = CostSpec::empty().with_merge();
3794 let result = inv.reduce(
3795 &Amount::new(dec!(-20), "AAPL"),
3796 Some(&merge_spec),
3797 BookingMethod::Strict,
3798 );
3799
3800 assert!(matches!(
3801 result,
3802 Err(BookingError::InsufficientUnits { .. })
3803 ));
3804 }
3805
3806 #[test]
3807 fn test_reduce_merge_sells_all() {
3808 // Merge and sell entire position
3809 let mut inv = Inventory::new();
3810 inv.add(Position::with_cost(
3811 Amount::new(dec!(10), "AAPL"),
3812 Cost::new(dec!(150), "USD"),
3813 ))
3814 .expect("fixture fits in Decimal");
3815 inv.add(Position::with_cost(
3816 Amount::new(dec!(10), "AAPL"),
3817 Cost::new(dec!(160), "USD"),
3818 ))
3819 .expect("fixture fits in Decimal");
3820
3821 let merge_spec = CostSpec::empty().with_merge();
3822 let result = inv
3823 .reduce(
3824 &Amount::new(dec!(-20), "AAPL"),
3825 Some(&merge_spec),
3826 BookingMethod::Strict,
3827 )
3828 .expect("merge reduction should succeed");
3829
3830 // Cost basis: 20 * 155 = 3100 USD
3831 assert_eq!(result.cost_basis, Some(Amount::new(dec!(3100), "USD")));
3832
3833 // Inventory should be empty
3834 assert!(inv.positions.is_empty() || inv.positions.iter().all(Position::is_empty));
3835 }
3836
3837 #[test]
3838 fn test_reduce_merge_single_lot() {
3839 // {*} with a single lot should work trivially
3840 let mut inv = Inventory::new();
3841 inv.add(Position::with_cost(
3842 Amount::new(dec!(10), "AAPL"),
3843 Cost::new(dec!(150), "USD"),
3844 ))
3845 .expect("fixture fits in Decimal");
3846
3847 let merge_spec = CostSpec::empty().with_merge();
3848 let result = inv
3849 .reduce(
3850 &Amount::new(dec!(-3), "AAPL"),
3851 Some(&merge_spec),
3852 BookingMethod::Strict,
3853 )
3854 .expect("single-lot merge should succeed");
3855
3856 assert_eq!(result.cost_basis, Some(Amount::new(dec!(450), "USD")));
3857 assert_eq!(inv.positions.len(), 1);
3858 // Iterator, not a raw slot: the merged lot is appended after the
3859 // tombstoned originals.
3860 let merged = inv.positions().next().expect("one merged lot");
3861 assert_eq!(merged.units.number, dec!(7));
3862 }
3863
3864 #[test]
3865 fn test_reduce_merge_three_lots() {
3866 // {*} with three lots at different costs
3867 let mut inv = Inventory::new();
3868 inv.add(Position::with_cost(
3869 Amount::new(dec!(10), "AAPL"),
3870 Cost::new(dec!(100), "USD"),
3871 ))
3872 .expect("fixture fits in Decimal");
3873 inv.add(Position::with_cost(
3874 Amount::new(dec!(10), "AAPL"),
3875 Cost::new(dec!(150), "USD"),
3876 ))
3877 .expect("fixture fits in Decimal");
3878 inv.add(Position::with_cost(
3879 Amount::new(dec!(10), "AAPL"),
3880 Cost::new(dec!(200), "USD"),
3881 ))
3882 .expect("fixture fits in Decimal");
3883
3884 // Average cost: (1000 + 1500 + 2000) / 30 = 150 USD
3885 let merge_spec = CostSpec::empty().with_merge();
3886 let result = inv
3887 .reduce(
3888 &Amount::new(dec!(-6), "AAPL"),
3889 Some(&merge_spec),
3890 BookingMethod::Strict,
3891 )
3892 .expect("three-lot merge should succeed");
3893
3894 assert_eq!(result.cost_basis, Some(Amount::new(dec!(900), "USD")));
3895 assert_eq!(inv.positions.len(), 1);
3896 // Iterator, not a raw slot: the merged lot is appended after the
3897 // tombstoned originals.
3898 let merged = inv.positions().next().expect("one merged lot");
3899 assert_eq!(merged.units.number, dec!(24));
3900 let cost = merged.cost.as_ref().expect("should have cost");
3901 assert_eq!(cost.number, dec!(150));
3902 }
3903
3904 #[test]
3905 fn test_reduce_merge_mixed_cost_currencies_errors() {
3906 // Lots with different cost currencies cannot be merged
3907 let mut inv = Inventory::new();
3908 inv.add(Position::with_cost(
3909 Amount::new(dec!(10), "AAPL"),
3910 Cost::new(dec!(150), "USD"),
3911 ))
3912 .expect("fixture fits in Decimal");
3913 inv.add(Position::with_cost(
3914 Amount::new(dec!(10), "AAPL"),
3915 Cost::new(dec!(130), "EUR"),
3916 ))
3917 .expect("fixture fits in Decimal");
3918
3919 let merge_spec = CostSpec::empty().with_merge();
3920 let result = inv.reduce(
3921 &Amount::new(dec!(-5), "AAPL"),
3922 Some(&merge_spec),
3923 BookingMethod::Strict,
3924 );
3925
3926 assert!(
3927 matches!(result, Err(BookingError::CurrencyMismatch { .. })),
3928 "expected CurrencyMismatch, got {result:?}"
3929 );
3930 }
3931
3932 #[test]
3933 fn test_reduce_merge_empty_inventory() {
3934 let mut inv = Inventory::new();
3935
3936 let merge_spec = CostSpec::empty().with_merge();
3937 let result = inv.reduce(
3938 &Amount::new(dec!(-5), "AAPL"),
3939 Some(&merge_spec),
3940 BookingMethod::Strict,
3941 );
3942
3943 assert!(matches!(
3944 result,
3945 Err(BookingError::InsufficientUnits { .. })
3946 ));
3947 }
3948
3949 #[test]
3950 fn test_inventory_display_sorted() {
3951 let mut inv = Inventory::new();
3952
3953 // Add in non-alphabetical order
3954 inv.add(Position::simple(Amount::new(dec!(100), "USD")))
3955 .expect("fixture fits in Decimal");
3956 inv.add(Position::simple(Amount::new(dec!(50), "EUR")))
3957 .expect("fixture fits in Decimal");
3958 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")))
3959 .expect("fixture fits in Decimal");
3960
3961 let display = format!("{inv}");
3962
3963 // Should be sorted alphabetically: AAPL, EUR, USD
3964 let aapl_pos = display.find("AAPL").unwrap();
3965 let eur_pos = display.find("EUR").unwrap();
3966 let usd_pos = display.find("USD").unwrap();
3967
3968 assert!(aapl_pos < eur_pos);
3969 assert!(eur_pos < usd_pos);
3970 }
3971
3972 #[test]
3973 fn test_inventory_with_cost_display_sorted() {
3974 let mut inv = Inventory::new();
3975
3976 // Add same currency with different costs
3977 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
3978 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
3979
3980 inv.add(Position::with_cost(
3981 Amount::new(dec!(10), "AAPL"),
3982 cost_high,
3983 ))
3984 .expect("fixture fits in Decimal");
3985 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low))
3986 .expect("fixture fits in Decimal");
3987
3988 let display = format!("{inv}");
3989
3990 // Both positions should be in the output
3991 assert!(display.contains("AAPL"));
3992 assert!(display.contains("100"));
3993 assert!(display.contains("200"));
3994 }
3995
3996 #[test]
3997 fn test_reduce_hifo_no_matching_lot() {
3998 let mut inv = Inventory::new();
3999
4000 // No AAPL positions
4001 inv.add(Position::simple(Amount::new(dec!(100), "USD")))
4002 .expect("fixture fits in Decimal");
4003
4004 let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
4005
4006 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
4007 }
4008
4009 #[test]
4010 fn test_fifo_respects_dates() {
4011 // Ensure FIFO uses acquisition date, not insertion order
4012 let mut inv = Inventory::new();
4013
4014 // Add newer lot first (out of order)
4015 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
4016 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4017
4018 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new))
4019 .expect("fixture fits in Decimal");
4020 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old))
4021 .expect("fixture fits in Decimal");
4022
4023 // FIFO should reduce from oldest (cost 100) first
4024 let result = inv
4025 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
4026 .unwrap();
4027
4028 // Should use cost from oldest lot (100)
4029 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
4030 }
4031
4032 #[test]
4033 fn test_lifo_respects_dates() {
4034 // Ensure LIFO uses acquisition date, not insertion order
4035 let mut inv = Inventory::new();
4036
4037 // Add older lot first
4038 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4039 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
4040
4041 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old))
4042 .expect("fixture fits in Decimal");
4043 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new))
4044 .expect("fixture fits in Decimal");
4045
4046 // LIFO should reduce from newest (cost 200) first
4047 let result = inv
4048 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
4049 .unwrap();
4050
4051 // Should use cost from newest lot (200)
4052 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
4053 }
4054
4055 // =========================================================================
4056 // Booking method coverage tests
4057 //
4058 // These tests cover gaps identified during the spring 2026 audit:
4059 // - STRICT_WITH_SIZE: cost spec + exact-size, multiple exact-size matches
4060 // - HIFO: multi-lot ordering, partial reduction, cost spec filtering
4061 // - AVERAGE: weighted average with different costs, partial reduction preserves cost
4062 // - NONE: with cost positions, short position reduction
4063 // =========================================================================
4064
4065 // --- STRICT_WITH_SIZE ---
4066
4067 #[test]
4068 fn test_strict_with_size_different_costs_exact_match() {
4069 // When lots have different costs but one matches the reduction size exactly,
4070 // STRICT_WITH_SIZE should pick that lot instead of raising AmbiguousMatch
4071 let mut inv = Inventory::new();
4072
4073 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4074 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4075
4076 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
4077 .expect("fixture fits in Decimal");
4078 inv.add(Position::with_cost(Amount::new(dec!(7), "AAPL"), cost2))
4079 .expect("fixture fits in Decimal");
4080
4081 // Reduce exactly 7 - should match the 7-share lot at cost 200
4082 let result = inv
4083 .reduce(
4084 &Amount::new(dec!(-7), "AAPL"),
4085 None,
4086 BookingMethod::StrictWithSize,
4087 )
4088 .unwrap();
4089
4090 assert_eq!(inv.units("AAPL"), dec!(10));
4091 assert_eq!(result.cost_basis.unwrap().number, dec!(1400.00)); // 7 * 200
4092 }
4093
4094 #[test]
4095 fn test_strict_with_size_multiple_exact_matches_picks_oldest() {
4096 // When multiple lots have the exact same size, STRICT_WITH_SIZE should
4097 // pick the oldest one (first in index order)
4098 let mut inv = Inventory::new();
4099
4100 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4101 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 6, 1));
4102
4103 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost1))
4104 .expect("fixture fits in Decimal");
4105 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
4106 .expect("fixture fits in Decimal");
4107
4108 // Both lots are size 5 — should pick the first (oldest) one
4109 let result = inv
4110 .reduce(
4111 &Amount::new(dec!(-5), "AAPL"),
4112 None,
4113 BookingMethod::StrictWithSize,
4114 )
4115 .unwrap();
4116
4117 assert_eq!(inv.units("AAPL"), dec!(5));
4118 // Should use cost from the oldest lot (100)
4119 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
4120 }
4121
4122 #[test]
4123 fn test_strict_with_size_with_cost_spec() {
4124 // Cost spec should filter lots before exact-size matching
4125 let mut inv = Inventory::new();
4126
4127 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4128 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4129
4130 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
4131 .expect("fixture fits in Decimal");
4132 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
4133 .expect("fixture fits in Decimal");
4134
4135 // With cost spec filtering to the 200 USD lot, should find unique match
4136 let spec = CostSpec::empty().with_number(crate::CostNumber::PerUnit {
4137 value: dec!(200.00),
4138 });
4139 let result = inv
4140 .reduce(
4141 &Amount::new(dec!(-5), "AAPL"),
4142 Some(&spec),
4143 BookingMethod::StrictWithSize,
4144 )
4145 .unwrap();
4146
4147 assert_eq!(inv.units("AAPL"), dec!(15));
4148 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
4149 }
4150
4151 // --- HIFO ---
4152
4153 #[test]
4154 fn test_hifo_reduces_highest_cost_first() {
4155 // HIFO should reduce the highest-cost lot first, regardless of date
4156 let mut inv = Inventory::new();
4157
4158 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4159 let cost_mid = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
4160 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
4161
4162 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low))
4163 .expect("fixture fits in Decimal");
4164 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid))
4165 .expect("fixture fits in Decimal");
4166 inv.add(Position::with_cost(
4167 Amount::new(dec!(10), "AAPL"),
4168 cost_high,
4169 ))
4170 .expect("fixture fits in Decimal");
4171
4172 // Reduce 5 — should come from highest cost lot (200)
4173 let result = inv
4174 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Hifo)
4175 .unwrap();
4176
4177 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
4178 assert_eq!(inv.units("AAPL"), dec!(25));
4179 }
4180
4181 #[test]
4182 fn test_hifo_spans_multiple_lots() {
4183 // When reducing more than the highest-cost lot holds, HIFO should
4184 // continue to the next highest
4185 let mut inv = Inventory::new();
4186
4187 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4188 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4189
4190 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low))
4191 .expect("fixture fits in Decimal");
4192 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_high))
4193 .expect("fixture fits in Decimal");
4194
4195 // Reduce 8: 5 from high (200) + 3 from low (100)
4196 let result = inv
4197 .reduce(&Amount::new(dec!(-8), "AAPL"), None, BookingMethod::Hifo)
4198 .unwrap();
4199
4200 // Cost basis: 5*200 + 3*100 = 1300
4201 assert_eq!(result.cost_basis.unwrap().number, dec!(1300.00));
4202 assert_eq!(inv.units("AAPL"), dec!(2));
4203 }
4204
4205 #[test]
4206 fn test_hifo_with_cost_spec_filter() {
4207 // Cost spec should filter lots before HIFO ordering
4208 let mut inv = Inventory::new();
4209
4210 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4211 let cost2 = Cost::new(dec!(200.00), "EUR").with_date(date(2024, 2, 1));
4212
4213 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
4214 .expect("fixture fits in Decimal");
4215 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
4216 .expect("fixture fits in Decimal");
4217
4218 // Filter to USD lots only
4219 let spec = CostSpec::empty().with_currency("USD");
4220 let result = inv
4221 .reduce(
4222 &Amount::new(dec!(-5), "AAPL"),
4223 Some(&spec),
4224 BookingMethod::Hifo,
4225 )
4226 .unwrap();
4227
4228 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); // 5 * 100 USD
4229 }
4230
4231 #[test]
4232 fn test_hifo_short_position() {
4233 // HIFO with short positions: covering shorts should work correctly
4234 let mut inv = Inventory::new();
4235
4236 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4237 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4238
4239 // Short positions (negative units)
4240 inv.add(Position::with_cost(
4241 Amount::new(dec!(-10), "AAPL"),
4242 cost_low,
4243 ))
4244 .expect("fixture fits in Decimal");
4245 inv.add(Position::with_cost(
4246 Amount::new(dec!(-10), "AAPL"),
4247 cost_high,
4248 ))
4249 .expect("fixture fits in Decimal");
4250
4251 // Cover 5 shares (positive = reduce short position)
4252 // HIFO should pick the highest-cost short lot (200)
4253 let result = inv
4254 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Hifo)
4255 .unwrap();
4256
4257 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
4258 assert_eq!(inv.units("AAPL"), dec!(-15));
4259 }
4260
4261 // --- AVERAGE ---
4262
4263 #[test]
4264 fn test_average_weighted_cost() {
4265 // AVERAGE should compute weighted average across lots with different costs
4266 let mut inv = Inventory::new();
4267
4268 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4269 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4270
4271 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
4272 .expect("fixture fits in Decimal");
4273 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
4274 .expect("fixture fits in Decimal");
4275
4276 // Average cost = (10*100 + 10*200) / 20 = 150
4277 let result = inv
4278 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
4279 .unwrap();
4280
4281 // Cost basis: 5 * 150 = 750
4282 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
4283 assert_eq!(inv.units("AAPL"), dec!(15));
4284 }
4285
4286 #[test]
4287 fn test_average_merges_into_single_position() {
4288 // After AVERAGE reduction, inventory should have a single simple position
4289 let mut inv = Inventory::new();
4290
4291 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4292 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4293
4294 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
4295 .expect("fixture fits in Decimal");
4296 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
4297 .expect("fixture fits in Decimal");
4298
4299 inv.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
4300 .unwrap();
4301
4302 // Should have exactly one AAPL position remaining
4303 let aapl_positions: Vec<_> = inv
4304 .positions
4305 .iter()
4306 .filter(|p| p.units.currency.as_ref() == "AAPL")
4307 .collect();
4308 assert_eq!(aapl_positions.len(), 1);
4309 assert_eq!(aapl_positions[0].units.number, dec!(15));
4310 }
4311
4312 #[test]
4313 fn test_average_uneven_lots() {
4314 // Weighted average with unequal lot sizes
4315 let mut inv = Inventory::new();
4316
4317 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4318 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4319
4320 inv.add(Position::with_cost(Amount::new(dec!(30), "AAPL"), cost1))
4321 .expect("fixture fits in Decimal");
4322 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
4323 .expect("fixture fits in Decimal");
4324
4325 // Average cost = (30*100 + 10*200) / 40 = 5000/40 = 125
4326 let result = inv
4327 .reduce(
4328 &Amount::new(dec!(-10), "AAPL"),
4329 None,
4330 BookingMethod::Average,
4331 )
4332 .unwrap();
4333
4334 assert_eq!(result.cost_basis.unwrap().number, dec!(1250.00)); // 10 * 125
4335 }
4336
4337 // --- NONE ---
4338
4339 #[test]
4340 fn test_none_booking_with_cost_positions() {
4341 // NONE booking should work even when positions have costs
4342 let mut inv = Inventory::new();
4343
4344 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4345 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
4346 .expect("fixture fits in Decimal");
4347
4348 let result = inv
4349 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::None)
4350 .unwrap();
4351
4352 assert_eq!(inv.units("AAPL"), dec!(5));
4353 // NONE delegates to reduce_ordered (FIFO) internally, so cost basis is computed
4354 assert!(result.cost_basis.is_some());
4355 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
4356 }
4357
4358 #[test]
4359 fn test_none_booking_short_cover() {
4360 // Covering a short position with NONE booking
4361 let mut inv = Inventory::new();
4362 inv.add(Position::simple(Amount::new(dec!(-100), "USD")))
4363 .expect("fixture fits in Decimal");
4364
4365 // Positive amount should reduce the negative position
4366 let result = inv
4367 .reduce(&Amount::new(dec!(30), "USD"), None, BookingMethod::None)
4368 .unwrap();
4369
4370 assert_eq!(inv.units("USD"), dec!(-70));
4371 assert!(!result.matched.is_empty());
4372 }
4373
4374 #[test]
4375 fn test_none_booking_empty_inventory_augments() {
4376 // NONE booking on empty inventory should augment
4377 let mut inv = Inventory::new();
4378
4379 let result = inv
4380 .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
4381 .unwrap();
4382
4383 assert_eq!(inv.units("USD"), dec!(50));
4384 assert!(result.matched.is_empty()); // Augmentation, not reduction
4385 }
4386
4387 // --- Cross-method: short positions ---
4388
4389 #[test]
4390 fn test_fifo_short_position_cover() {
4391 // FIFO: cover short positions (oldest short first)
4392 let mut inv = Inventory::new();
4393
4394 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4395 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
4396
4397 inv.add(Position::with_cost(
4398 Amount::new(dec!(-10), "AAPL"),
4399 cost_old,
4400 ))
4401 .expect("fixture fits in Decimal");
4402 inv.add(Position::with_cost(
4403 Amount::new(dec!(-10), "AAPL"),
4404 cost_new,
4405 ))
4406 .expect("fixture fits in Decimal");
4407
4408 // Cover 5 shares — FIFO should pick oldest short (cost 100)
4409 let result = inv
4410 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Fifo)
4411 .unwrap();
4412
4413 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); // 5 * 100
4414 assert_eq!(inv.units("AAPL"), dec!(-15));
4415 }
4416
4417 #[test]
4418 fn test_lifo_short_position_cover() {
4419 // LIFO: cover short positions (newest short first)
4420 let mut inv = Inventory::new();
4421
4422 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4423 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
4424
4425 inv.add(Position::with_cost(
4426 Amount::new(dec!(-10), "AAPL"),
4427 cost_old,
4428 ))
4429 .expect("fixture fits in Decimal");
4430 inv.add(Position::with_cost(
4431 Amount::new(dec!(-10), "AAPL"),
4432 cost_new,
4433 ))
4434 .expect("fixture fits in Decimal");
4435
4436 // Cover 5 shares — LIFO should pick newest short (cost 200)
4437 let result = inv
4438 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Lifo)
4439 .unwrap();
4440
4441 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
4442 assert_eq!(inv.units("AAPL"), dec!(-15));
4443 }
4444
4445 // === AccountedBookingError Display tests ===
4446 //
4447 // These tests pin the canonical user-facing wording for every variant
4448 // of `AccountedBookingError`. The whole point of unifying booking-error
4449 // Display into `rustledger-core` (#750) is that there's a single source
4450 // of truth — and a single source of truth with no tests is one refactor
4451 // away from drifting again, which is exactly the failure mode that
4452 // produced #748. Any change to the Display strings below will break
4453 // these tests, forcing the author to consciously re-check pta-standards
4454 // conformance assertions and downstream user tooling.
4455
4456 // =========================================================================
4457 // Regression test for issue #875 / beancount#889
4458 //
4459 // When a sell-without-cost-spec leaves a negative simple position in the
4460 // inventory, a subsequent augmentation WITH a cost spec should NOT be
4461 // misclassified as a reduction. `is_reduced_by` must only consider
4462 // cost-bearing positions when the incoming posting has a cost spec.
4463 // =========================================================================
4464
4465 #[test]
4466 fn test_is_reduced_by_ignores_simple_positions_when_has_cost_spec() {
4467 // Regression test for issue #875 / beancount#889.
4468 //
4469 // Scenario:
4470 // 1. Buy 100 HOOG {1.50 EUR} -> inventory: [100 HOOG {1.50 EUR}]
4471 // 2. Sell 25 HOOG @ 1.60 EUR -> inventory: [100 HOOG {1.50 EUR}, -25 HOOG (simple)]
4472 // 3. Buy 50 HOOG {1.70 EUR} -> should be augmentation, NOT reduction
4473 //
4474 // Before fix: is_reduced_by saw the -25 HOOG simple position and
4475 // incorrectly reported that +50 HOOG would reduce the inventory.
4476 let mut inv = Inventory::new();
4477
4478 // Step 1: buy 100 HOOG with cost
4479 let cost = Cost::new(dec!(1.50), "EUR").with_date(date(2024, 1, 10));
4480 inv.add(Position::with_cost(Amount::new(dec!(100), "HOOG"), cost))
4481 .expect("fixture fits in Decimal");
4482
4483 // Step 2: sell 25 HOOG without cost spec (simple position)
4484 inv.add(Position::simple(Amount::new(dec!(-25), "HOOG")))
4485 .expect("fixture fits in Decimal");
4486
4487 // Step 3: check if buying 50 HOOG with cost spec would be a reduction
4488 let buy_units = Amount::new(dec!(50), "HOOG");
4489
4490 // With has_cost_spec=true, only cost-bearing positions should be
4491 // considered. The 100 HOOG {1.50 EUR} is positive and so is the
4492 // incoming 50 HOOG -> same sign -> NOT a reduction.
4493 assert!(
4494 !inv.is_reduced_by(&buy_units, ReductionScope::CostBearingOnly),
4495 "augmentation with cost spec should NOT be treated as reduction \
4496 when only a simple (no-cost) position has opposite sign"
4497 );
4498
4499 // With AllPositions, all positions are considered,
4500 // including the -25 HOOG simple position -> IS a reduction.
4501 assert!(
4502 inv.is_reduced_by(&buy_units, ReductionScope::AllPositions),
4503 "without cost spec filter, the -25 HOOG simple position \
4504 should cause is_reduced_by to return true"
4505 );
4506 }
4507
4508 #[test]
4509 fn is_booking_reduction_gates_on_method_cost_and_sign() {
4510 // A cost-bearing long position.
4511 let mut inv = Inventory::new();
4512 inv.add(Position::with_cost(
4513 Amount::new(dec!(10), "AAPL"),
4514 Cost::new(dec!(150), "USD").with_date(date(2024, 1, 1)),
4515 ))
4516 .expect("fixture fits in Decimal");
4517
4518 let sell = Amount::new(dec!(-5), "AAPL"); // opposite sign of the held lot
4519 let buy = Amount::new(dec!(5), "AAPL"); // same sign
4520 let spec = CostSpec::empty(); // only spec *presence* (is_some) matters here
4521
4522 // Opposite-sign units carrying a cost spec under a lot-matching method
4523 // is the one combination that reduces.
4524 assert!(inv.is_booking_reduction(&sell, Some(&spec), BookingMethod::Strict));
4525 // NONE never reduces — every posting accumulates (#1182).
4526 assert!(!inv.is_booking_reduction(&sell, Some(&spec), BookingMethod::None));
4527 // No cost spec -> augmentation.
4528 assert!(!inv.is_booking_reduction(&sell, None, BookingMethod::Strict));
4529 // Same sign as the held lot -> augmentation.
4530 assert!(!inv.is_booking_reduction(&buy, Some(&spec), BookingMethod::Strict));
4531 }
4532
4533 #[test]
4534 fn sum_account_and_subaccounts_sums_children_not_prefix_siblings() {
4535 let mut bank = Inventory::new();
4536 bank.add(Position::simple(Amount::new(dec!(10), "USD")))
4537 .expect("fixture fits in Decimal");
4538 let mut checking = Inventory::new(); // sub-account: included
4539 checking
4540 .add(Position::simple(Amount::new(dec!(40), "USD")))
4541 .expect("fixture fits in Decimal");
4542 let mut alias = Inventory::new(); // prefix sibling: excluded
4543 alias
4544 .add(Position::simple(Amount::new(dec!(99), "USD")))
4545 .expect("fixture fits in Decimal");
4546
4547 let mut map: FxHashMap<Account, Inventory> = FxHashMap::default();
4548 map.insert(Account::from("Assets:Bank"), bank);
4549 map.insert(Account::from("Assets:Bank:Checking"), checking);
4550 map.insert(Account::from("Assets:BankAlias"), alias);
4551
4552 let total = sum_account_and_subaccounts(map.iter(), "Assets:Bank", &Currency::from("USD"))
4553 .expect("fixture fits in Decimal");
4554 assert_eq!(
4555 total,
4556 dec!(50),
4557 "parent (10) + sub-account (40), excluding the Assets:BankAlias prefix sibling"
4558 );
4559 }
4560
4561 #[test]
4562 fn test_accounted_error_display_insufficient_units() {
4563 let err = BookingError::InsufficientUnits {
4564 currency: "AAPL".into(),
4565 requested: dec!(15),
4566 available: dec!(10),
4567 }
4568 .with_account("Assets:Stock".into());
4569 let rendered = format!("{err}");
4570
4571 // Pinned by pta-standards `reduction-exceeds-inventory`
4572 // (`error_contains: ["not enough"]`). See #748 / #749.
4573 assert!(
4574 rendered.contains("not enough"),
4575 "must contain 'not enough' (pta-standards): {rendered}"
4576 );
4577 assert!(
4578 rendered.contains("Assets:Stock"),
4579 "must contain account name: {rendered}"
4580 );
4581 assert!(
4582 rendered.contains("15") && rendered.contains("10"),
4583 "must contain requested and available amounts: {rendered}"
4584 );
4585 }
4586
4587 #[test]
4588 fn test_accounted_error_display_no_matching_lot() {
4589 let err = BookingError::NoMatchingLot {
4590 currency: "AAPL".into(),
4591 cost_spec: CostSpec::empty(),
4592 }
4593 .with_account("Assets:Stock".into());
4594 let rendered = format!("{err}");
4595
4596 assert!(
4597 rendered.contains("No matching lot"),
4598 "must contain 'No matching lot': {rendered}"
4599 );
4600 assert!(
4601 rendered.contains("AAPL"),
4602 "must contain currency: {rendered}"
4603 );
4604 assert!(
4605 rendered.contains("Assets:Stock"),
4606 "must contain account name: {rendered}"
4607 );
4608 }
4609
4610 #[test]
4611 fn test_accounted_error_display_ambiguous_match() {
4612 let err = BookingError::AmbiguousMatch {
4613 num_matches: 3,
4614 currency: "AAPL".into(),
4615 }
4616 .with_account("Assets:Stock".into());
4617 let rendered = format!("{err}");
4618
4619 assert!(
4620 rendered.contains("Ambiguous"),
4621 "must contain 'Ambiguous': {rendered}"
4622 );
4623 assert!(
4624 rendered.contains("AAPL"),
4625 "must contain currency: {rendered}"
4626 );
4627 assert!(
4628 rendered.contains("Assets:Stock"),
4629 "must contain account name: {rendered}"
4630 );
4631 assert!(
4632 rendered.contains('3'),
4633 "must contain match count: {rendered}"
4634 );
4635 }
4636
4637 #[test]
4638 fn test_accounted_error_display_currency_mismatch_renders_as_no_matching_lot() {
4639 // CurrencyMismatch is semantically a specialization of NoMatchingLot
4640 // (there is no lot for the given currency in this inventory) and the
4641 // canonical Display collapses them into the same user-facing phrasing
4642 // so that consumers filtering on E4001 don't need to special-case it.
4643 // This variant is defensive — no `Inventory::reduce` path currently
4644 // emits it — but we still pin its rendering in case a future emission
4645 // site is added.
4646 let err = BookingError::CurrencyMismatch {
4647 expected: "USD".into(),
4648 got: "EUR".into(),
4649 }
4650 .with_account("Assets:Cash".into());
4651 let rendered = format!("{err}");
4652
4653 assert!(
4654 rendered.contains("No matching lot"),
4655 "CurrencyMismatch must render as 'No matching lot' for E4001 \
4656 consistency: {rendered}"
4657 );
4658 assert!(
4659 rendered.contains("EUR"),
4660 "must contain the mismatched (got) currency: {rendered}"
4661 );
4662 assert!(
4663 rendered.contains("Assets:Cash"),
4664 "must contain account name: {rendered}"
4665 );
4666 }
4667
4668 /// `sign_index` must agree with a scan after EVERY mutation path, not
4669 /// just the ones a given test happens to follow with an
4670 /// `is_reduced_by` call.
4671 ///
4672 /// `is_reduced_by`'s own `debug_assert` compares the two on every call,
4673 /// which covers the whole suite — but only where something calls it.
4674 /// This walks the mutations that can move a lot between buckets and
4675 /// checks after each: a cost-less merge that flips a lot's sign by adding
4676 /// through zero, a reduction that takes a lot to exactly zero (removing
4677 /// it), and a partial reduction that leaves it. The comparison is
4678 /// explicit rather than leaning on the assertion, so it holds in release
4679 /// builds too.
4680 #[test]
4681 fn the_sign_index_tracks_every_mutation_path() {
4682 let usd = Amount::new(dec!(1), "USD");
4683 let aapl = Amount::new(dec!(1), "AAPL");
4684 let check = |inv: &Inventory, label: &str| {
4685 // The incrementally maintained counts must equal what a fresh
4686 // rebuild computes. This is the invariant that matters, and it is
4687 // strictly stronger than "the answers agree": an empty cache
4688 // still ANSWERS correctly, because `is_reduced_by` falls back to
4689 // the scan — so a path that quietly stopped maintaining the counts
4690 // would restore the O(lots) cost with every test still green.
4691 // Comparing against a rebuild catches that, and catches a broken
4692 // rebuild too, since the two are independent code.
4693 //
4694 // Zero-count entries are filtered from both sides: `units_cache`
4695 // keeps a currency's entry for its running total after the last
4696 // lot closes, which a rebuild has no reason to create.
4697 let counts_of = |inv: &Inventory| {
4698 inv.units_cache
4699 .iter()
4700 .filter(|(_, stats)| stats.counts != SignCounts::default())
4701 .map(|(currency, stats)| (currency.as_str().to_string(), stats.counts))
4702 .collect::<std::collections::BTreeMap<_, _>>()
4703 };
4704 let mut rebuilt = inv.clone();
4705 rebuilt.rebuild_index();
4706 assert_eq!(
4707 counts_of(inv),
4708 counts_of(&rebuilt),
4709 "the incrementally maintained sign counts diverged from a \
4710 fresh rebuild after {label}",
4711 );
4712 for units in [&usd, &aapl] {
4713 for signed in [
4714 units.clone(),
4715 Amount::new(-units.number, units.currency.clone()),
4716 ] {
4717 for scope in [
4718 ReductionScope::AllPositions,
4719 ReductionScope::CostBearingOnly,
4720 ] {
4721 assert_eq!(
4722 inv.is_reduced_by(&signed, scope),
4723 inv.is_reduced_by_scan(&signed, scope),
4724 "the sign counts disagree with a scan after {label} \
4725 for {signed:?} / {scope:?}",
4726 );
4727 }
4728 }
4729 }
4730 };
4731
4732 let mut inv = Inventory::new();
4733 check(&inv, "empty");
4734
4735 // Cost-less lot, then a merge that takes it negative through zero.
4736 inv.add(Position::simple(Amount::new(dec!(3), "USD")))
4737 .expect("fits");
4738 check(&inv, "one simple lot");
4739 inv.add(Position::simple(Amount::new(dec!(-8), "USD")))
4740 .expect("fits");
4741 check(&inv, "simple lot flipped negative by merge");
4742 inv.add(Position::simple(Amount::new(dec!(8), "USD")))
4743 .expect("fits");
4744 check(&inv, "simple lot flipped back positive");
4745
4746 // Cost-bearing lots, then reductions that partially and fully drain.
4747 let cost = Cost::new(dec!(100), "USD");
4748 inv.add(Position::with_cost(
4749 Amount::new(dec!(10), "AAPL"),
4750 cost.clone(),
4751 ))
4752 .expect("fits");
4753 check(&inv, "one cost-bearing lot");
4754
4755 inv.reduce(
4756 &Amount::new(dec!(-4), "AAPL"),
4757 Some(&CostSpec::default()),
4758 BookingMethod::Fifo,
4759 )
4760 .expect("partial reduction");
4761 check(&inv, "partially reduced lot");
4762
4763 inv.reduce(
4764 &Amount::new(dec!(-6), "AAPL"),
4765 Some(&CostSpec::default()),
4766 BookingMethod::Fifo,
4767 )
4768 .expect("full reduction");
4769 check(&inv, "fully drained lot");
4770
4771 // STRICT with a single matching lot takes the OTHER commit path —
4772 // `commit_from_lot`, which maintains the caches incrementally instead
4773 // of rebuilding. A FIFO-only test leaves it completely uncovered.
4774 let mut strict = Inventory::new();
4775 strict
4776 .add(Position::with_cost(
4777 Amount::new(dec!(10), "AAPL"),
4778 cost.clone(),
4779 ))
4780 .expect("fits");
4781 check(&strict, "strict: one lot");
4782 strict
4783 .reduce(
4784 &Amount::new(dec!(-4), "AAPL"),
4785 Some(&CostSpec::default()),
4786 BookingMethod::Strict,
4787 )
4788 .expect("partial strict reduction");
4789 check(&strict, "strict: partially reduced");
4790 strict
4791 .reduce(
4792 &Amount::new(dec!(-6), "AAPL"),
4793 Some(&CostSpec::default()),
4794 BookingMethod::Strict,
4795 )
4796 .expect("draining strict reduction");
4797 check(&strict, "strict: lot drained and removed");
4798 assert!(
4799 strict.positions.is_empty(),
4800 "the fixture must actually remove the lot, or the removal path is \
4801 untested",
4802 );
4803
4804 // A SHORT lot covered to exactly zero. This is the only shape where a
4805 // reduction changes a lot's bucket: `is_sign_positive` answers TRUE
4806 // for zero, so a negative lot reaching 0 moves from the negative
4807 // bucket to the positive one in the instant before it is removed.
4808 // Skipping the reclassify then decrements the wrong bucket and leaves
4809 // the index claiming a short lot that no longer exists. A long lot
4810 // cannot show this — it is capped at zero from above and never leaves
4811 // the positive bucket.
4812 let mut short = Inventory::new();
4813 short
4814 .add(Position::with_cost(
4815 Amount::new(dec!(-5), "AAPL"),
4816 Cost::new(dec!(100), "USD"),
4817 ))
4818 .expect("fits");
4819 check(&short, "short: one negative lot");
4820 short
4821 .reduce(
4822 &Amount::new(dec!(5), "AAPL"),
4823 Some(&CostSpec::default()),
4824 BookingMethod::Strict,
4825 )
4826 .expect("covering the short");
4827 check(&short, "short: covered to zero and removed");
4828 assert!(
4829 short.positions.is_empty(),
4830 "the short must actually close, or the bucket flip is untested",
4831 );
4832
4833 // And a rebuild must land on the same state as the incremental path.
4834 // Captured from an inventory whose last mutation was `commit_from_lot`
4835 // (no rebuild), so the two are genuinely independent here.
4836 let mut incremental_inv = Inventory::new();
4837 incremental_inv
4838 .add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
4839 .expect("fits");
4840 incremental_inv
4841 .reduce(
4842 &Amount::new(dec!(-4), "AAPL"),
4843 Some(&CostSpec::default()),
4844 BookingMethod::Strict,
4845 )
4846 .expect("partial strict reduction");
4847 let incremental = incremental_inv.units_cache.clone();
4848 assert!(!incremental.is_empty(), "fixture holds a lot");
4849 incremental_inv.rebuild_index();
4850 assert_eq!(
4851 incremental, incremental_inv.units_cache,
4852 "the incrementally maintained index must equal a fresh rebuild",
4853 );
4854 }
4855
4856 /// An inventory whose caches were never built still answers
4857 /// `is_reduced_by` correctly.
4858 ///
4859 /// The caches are `#[serde(skip)]`. Deserialization rebuilds them, but
4860 /// `positions_mut` hands out the position vector directly, so an
4861 /// inventory CAN hold lots with an empty cache. Reading the counts then
4862 /// would answer "not a reduction" for an inventory that plainly holds a
4863 /// matching lot — booking a sale as a purchase and duplicating the lot,
4864 /// which is the #875-class bug this predicate exists to prevent.
4865 ///
4866 /// So the unbuilt case falls back to the scan.
4867 ///
4868 /// The cache is cleared DIRECTLY here. It used to be reached through
4869 /// `positions_mut`, which handed out the position vector and left the
4870 /// caches describing the old contents — that accessor is gone, and its
4871 /// replacement `modify_positions` rebuilds them, so no public API produces
4872 /// this state any more. The fallback stays because `units_cache` is
4873 /// `#[serde(skip)]` and answering "not a reduction" for an inventory that
4874 /// plainly holds a matching lot is the unsafe direction; this constructs
4875 /// the state the only way left, and says so.
4876 #[test]
4877 fn an_unbuilt_cache_falls_back_to_the_scan_rather_than_answering_no() {
4878 let mut inv = Inventory::new();
4879 inv.add(Position::with_cost(
4880 Amount::new(dec!(10), "AAPL"),
4881 Cost::new(dec!(100), "USD"),
4882 ))
4883 .expect("fits");
4884 inv.units_cache.clear();
4885 assert!(
4886 inv.units_cache.is_empty(),
4887 "the fixture must reach `is_reduced_by` with an unbuilt cache, or \
4888 it is testing the fast path instead",
4889 );
4890
4891 assert!(
4892 inv.is_reduced_by(
4893 &Amount::new(dec!(-4), "AAPL"),
4894 ReductionScope::CostBearingOnly
4895 ),
4896 "a sale against a held lot must be seen as a reduction even with \
4897 no cache built",
4898 );
4899 assert!(
4900 !inv.is_reduced_by(
4901 &Amount::new(dec!(4), "AAPL"),
4902 ReductionScope::CostBearingOnly
4903 ),
4904 "a purchase in the same direction is still an augmentation",
4905 );
4906
4907 // And once the caches are built, the answers are unchanged.
4908 inv.rebuild_index();
4909 assert!(!inv.units_cache.is_empty(), "rebuild populates the cache");
4910 assert!(inv.is_reduced_by(
4911 &Amount::new(dec!(-4), "AAPL"),
4912 ReductionScope::CostBearingOnly
4913 ));
4914 assert!(!inv.is_reduced_by(
4915 &Amount::new(dec!(4), "AAPL"),
4916 ReductionScope::CostBearingOnly
4917 ));
4918 }
4919
4920 /// A cost-less lot sitting after a removed one keeps working.
4921 ///
4922 /// Removal used to shift every later position down one, so `simple_index`
4923 /// — which stores positions BY INDEX — had to be repaired to follow the
4924 /// shift. With tombstones nothing moves, so the entry must be left exactly
4925 /// where it is; repairing it now would point `add`'s merge at the wrong
4926 /// slot. Same test, opposite mechanism, and the consequence it guards is
4927 /// unchanged: a later cost-less deposit must MERGE rather than duplicate.
4928 ///
4929 /// Nothing else in the suite covers it: it needs a cost-bearing lot and a
4930 /// cost-less lot in the same inventory, with the cost-bearing one removed
4931 /// first, and inventories in most tests hold only one kind.
4932 #[test]
4933 fn removing_a_lot_repairs_the_index_of_a_later_cost_less_lot() {
4934 let mut inv = Inventory::new();
4935 // Index 0: cost-bearing. Index 1: cost-less, so `simple_index` says 1.
4936 inv.add(Position::with_cost(
4937 Amount::new(dec!(10), "AAPL"),
4938 Cost::new(dec!(100), "USD"),
4939 ))
4940 .expect("fits");
4941 inv.add(Position::simple(Amount::new(dec!(50), "USD")))
4942 .expect("fits");
4943 assert_eq!(
4944 inv.units_cache
4945 .get(&crate::Currency::new("USD"))
4946 .and_then(|s| s.simple_slot),
4947 Some(1),
4948 "fixture must put the cost-less lot second, or the shift is untested",
4949 );
4950
4951 // Drain the cost-bearing lot. STRICT takes the single-lot commit path,
4952 // which tombstones the slot in place.
4953 inv.reduce(
4954 &Amount::new(dec!(-10), "AAPL"),
4955 Some(&CostSpec::default()),
4956 BookingMethod::Strict,
4957 )
4958 .expect("drains the lot");
4959
4960 assert_eq!(
4961 inv.units_cache
4962 .get(&crate::Currency::new("USD"))
4963 .and_then(|s| s.simple_slot),
4964 Some(1),
4965 "the cost-less lot did not move, so its stored slot must not change",
4966 );
4967
4968 // The consequence a stale index actually has: this must MERGE into the
4969 // existing lot, not append a second one.
4970 inv.add(Position::simple(Amount::new(dec!(25), "USD")))
4971 .expect("fits");
4972 assert_eq!(
4973 inv.positions().count(),
4974 1,
4975 "a stale simple_index appends a duplicate cost-less lot instead of \
4976 merging",
4977 );
4978 assert_eq!(inv.units("USD"), dec!(75));
4979 }
4980
4981 /// Reducing a COST-LESS lot to zero removes it, and `simple_index` points
4982 /// at exactly that lot — so the entry must go, not just shift.
4983 #[test]
4984 fn removing_a_cost_less_lot_drops_its_index_entry() {
4985 let mut inv = Inventory::new();
4986 inv.add(Position::simple(Amount::new(dec!(50), "USD")))
4987 .expect("fits");
4988 assert_eq!(
4989 inv.units_cache
4990 .get(&crate::Currency::new("USD"))
4991 .and_then(|s| s.simple_slot),
4992 Some(0)
4993 );
4994
4995 // An empty spec matches a cost-less lot (`matches_cost_spec`:
4996 // `(None, true) => true`), so STRICT selects it and drains it.
4997 inv.reduce(
4998 &Amount::new(dec!(-50), "USD"),
4999 Some(&CostSpec::default()),
5000 BookingMethod::Strict,
5001 )
5002 .expect("drains the cost-less lot");
5003
5004 assert!(inv.positions().next().is_none(), "the lot is gone");
5005 assert_eq!(
5006 inv.units_cache
5007 .get(&crate::Currency::new("USD"))
5008 .and_then(|s| s.simple_slot),
5009 None,
5010 "a stale entry points at a removed lot; the next cost-less add \
5011 indexes past the end",
5012 );
5013
5014 // The consequence: this must not panic and must create a fresh lot.
5015 inv.add(Position::simple(Amount::new(dec!(20), "USD")))
5016 .expect("fits");
5017 assert_eq!(inv.units("USD"), dec!(20));
5018 }
5019
5020 /// Every index `iter_slots` yields must address, through `Index`, the very
5021 /// position it was yielded with.
5022 ///
5023 /// This is trivially true while the backing store is dense — `iter_slots`
5024 /// is `iter().enumerate()` — and it is the whole reason that method
5025 /// exists. The reduction paths collect indices from it and hand them back
5026 /// through `Index`/`IndexMut` to mutate the lot they selected. If the
5027 /// store ever becomes sparse (tombstoned lots, so a cost-keyed index can
5028 /// survive removals) and `iter_slots` keeps counting from zero instead of
5029 /// reporting real slots, every reduction after the first hole mutates the
5030 /// WRONG LOT — silently, with correct-looking totals.
5031 ///
5032 /// So this pins the contract rather than the current implementation.
5033 #[test]
5034 fn iter_slots_yields_indices_that_address_their_own_position() {
5035 let mut inv = Inventory::new();
5036 for units in [dec!(10), dec!(20), dec!(30)] {
5037 inv.add(Position::with_cost(
5038 Amount::new(units, "AAPL"),
5039 Cost::new(units * dec!(10), "USD"),
5040 ))
5041 .expect("fits");
5042 }
5043 // Two lots IDENTICAL by value. `add` never merges cost-bearing lots —
5044 // it keeps them separate to match Python — so this is an ordinary
5045 // inventory, and it is the shape that makes the assertion below
5046 // meaningful: with only distinct lots, comparing by value cannot tell
5047 // "the right slot" from "a slot holding an equal position".
5048 for _ in 0..2 {
5049 inv.add(Position::with_cost(
5050 Amount::new(dec!(7), "AAPL"),
5051 Cost::new(dec!(70), "USD"),
5052 ))
5053 .expect("fits");
5054 }
5055 inv.add(Position::simple(Amount::new(dec!(99), "USD")))
5056 .expect("fits");
5057
5058 let mut seen = 0;
5059 for (slot, position) in inv.positions.iter_slots() {
5060 // Pointer identity, not `assert_eq!`. `Position: PartialEq`, so a
5061 // value comparison passes when a wrong index happens to land on an
5062 // equal lot — exactly what the duplicate pair above arranges.
5063 // Review catch on #2065.
5064 assert!(
5065 std::ptr::eq(std::ptr::from_ref(&inv.positions[slot]), position),
5066 "slot {slot} addresses a different position than the one it \
5067 was yielded with",
5068 );
5069 seen += 1;
5070 }
5071 assert_eq!(
5072 seen,
5073 inv.positions().count(),
5074 "iter_slots must visit every live position",
5075 );
5076 assert_eq!(seen, 6, "fixture must hold six lots, two of them equal");
5077 }
5078
5079 /// The point of tombstoning: removing a lot must not renumber the lots
5080 /// after it.
5081 ///
5082 /// Shifting was what made a cost-keyed match index impossible — every
5083 /// removal invalidated every later index. This is the property the whole
5084 /// sparse backing exists to provide, so it is asserted directly rather
5085 /// than inferred from something downstream.
5086 #[test]
5087 fn a_removal_does_not_renumber_the_lots_after_it() {
5088 let mut inv = Inventory::new();
5089 for units in [dec!(10), dec!(20), dec!(30)] {
5090 inv.add(Position::with_cost(
5091 Amount::new(units, "AAPL"),
5092 Cost::new(units * dec!(10), "USD"),
5093 ))
5094 .expect("fits");
5095 }
5096 let before: Vec<usize> = inv.positions.iter_slots().map(|(slot, _)| slot).collect();
5097 assert_eq!(before, vec![0, 1, 2], "fixture must fill three slots");
5098
5099 // Drain the MIDDLE lot, so a shift would move the one after it.
5100 inv.reduce(
5101 &Amount::new(dec!(-20), "AAPL"),
5102 Some(
5103 &CostSpec::empty()
5104 .with_number(crate::CostNumber::PerUnit { value: dec!(200) })
5105 .with_currency("USD"),
5106 ),
5107 BookingMethod::Strict,
5108 )
5109 .expect("drains the middle lot");
5110
5111 let after: Vec<(usize, Decimal)> = inv
5112 .positions
5113 .iter_slots()
5114 .map(|(slot, p)| (slot, p.units.number))
5115 .collect();
5116 assert_eq!(
5117 after,
5118 vec![(0, dec!(10)), (2, dec!(30))],
5119 "the surviving lots must keep the slots they had; slot 1 is now a \
5120 tombstone and slot 2 must NOT have become slot 1",
5121 );
5122 assert_eq!(inv.positions.len(), 2, "two live lots");
5123 assert_eq!(inv.positions.slot_count(), 3, "three slots, one dead");
5124 }
5125
5126 /// Tombstones must not pile up without bound.
5127 ///
5128 /// Without compaction a long-lived account accumulates one dead slot per
5129 /// closed lot, and every scan walks all of them — matching would degrade
5130 /// toward "every lot this account ever held", which is far worse than the
5131 /// shifting it replaced.
5132 ///
5133 /// `reduce` compacts on its own whenever no undo log is open, which is the
5134 /// case for every caller except a transaction in flight. This drives it
5135 /// exactly as the Late validator does — no engine, no explicit call —
5136 /// because that is the caller that would otherwise grow a dead slot per
5137 /// closed lot for the life of the ledger.
5138 #[test]
5139 fn tombstones_are_compacted_rather_than_accumulating() {
5140 let mut inv = Inventory::new();
5141 for i in 0..50u32 {
5142 let cost = Decimal::from(100 + i);
5143 inv.add(Position::with_cost(
5144 Amount::new(dec!(1), "AAPL"),
5145 Cost::new(cost, "USD"),
5146 ))
5147 .expect("fits");
5148 inv.reduce(
5149 &Amount::new(dec!(-1), "AAPL"),
5150 Some(
5151 &CostSpec::empty()
5152 .with_number(crate::CostNumber::PerUnit { value: cost })
5153 .with_currency("USD"),
5154 ),
5155 BookingMethod::Strict,
5156 )
5157 .expect("drains it again");
5158 }
5159 assert_eq!(inv.positions.len(), 0, "every lot was closed");
5160 assert!(
5161 inv.positions.slot_count() <= 4,
5162 "50 open-and-close cycles left {} slots; compaction is not running, \
5163 and every later scan pays for all of them",
5164 inv.positions.slot_count(),
5165 );
5166 }
5167
5168 /// Draining a lot must take it out of the cost index.
5169 ///
5170 /// A stale entry is not merely wasteful: the slot it names is a tombstone,
5171 /// and it would be handed to the reduction path as a candidate. The lookup
5172 /// tolerates that by design, but the index still has to be maintained —
5173 /// otherwise the lists grow without bound as lots close, and the whole
5174 /// point of the index erodes. Asserted directly, because the tolerant
5175 /// lookup means no behavioral test can see the difference.
5176 #[test]
5177 fn draining_a_lot_removes_it_from_the_cost_index() {
5178 let spec = || {
5179 CostSpec::empty()
5180 .with_number(crate::CostNumber::PerUnit { value: dec!(100) })
5181 .with_currency("USD")
5182 };
5183 let mut inv = Inventory::new();
5184 inv.add(Position::with_cost(
5185 Amount::new(dec!(10), "AAPL"),
5186 Cost::new(dec!(100), "USD"),
5187 ))
5188 .expect("fits");
5189 assert_eq!(inv.cost_index.len(), 1, "the lot is indexed");
5190
5191 inv.reduce(
5192 &Amount::new(dec!(-10), "AAPL"),
5193 Some(&spec()),
5194 BookingMethod::Strict,
5195 )
5196 .expect("drains the lot");
5197
5198 assert!(
5199 inv.cost_index.is_empty(),
5200 "the drained lot is still indexed: {:?}",
5201 inv.cost_index,
5202 );
5203
5204 // And the same cost can be re-used afterwards without tripping over
5205 // the old slot — the case a stale entry would reach.
5206 inv.add(Position::with_cost(
5207 Amount::new(dec!(5), "AAPL"),
5208 Cost::new(dec!(100), "USD"),
5209 ))
5210 .expect("fits");
5211 let result = inv
5212 .reduce(
5213 &Amount::new(dec!(-5), "AAPL"),
5214 Some(&spec()),
5215 BookingMethod::Strict,
5216 )
5217 .expect("re-buying at the same cost and selling must work");
5218 assert_eq!(result.matched.len(), 1);
5219 assert_eq!(inv.positions.len(), 0);
5220 }
5221
5222 /// `modify_positions` hands over a DENSE vector and rebuilds every cache.
5223 ///
5224 /// It replaced `positions_mut`, which returned the backing vector
5225 /// directly. Two promises to keep: the closure must never see tombstones
5226 /// (the sparse backing is an implementation detail), and everything
5227 /// derived — units totals, the cost-less merge index, the cost index and
5228 /// the sign counts — must describe what the closure left, not what was
5229 /// there before. The old accessor kept none of that, which its own docs
5230 /// warned about.
5231 #[test]
5232 fn modify_positions_hands_over_a_dense_vector_and_rebuilds_the_caches() {
5233 let mut inv = Inventory::new();
5234 for units in [dec!(10), dec!(20)] {
5235 inv.add(Position::with_cost(
5236 Amount::new(units, "AAPL"),
5237 Cost::new(units * dec!(10), "USD"),
5238 ))
5239 .expect("fits");
5240 }
5241 // Drain the first lot so a tombstone exists before the handover.
5242 inv.reduce(
5243 &Amount::new(dec!(-10), "AAPL"),
5244 Some(
5245 &CostSpec::empty()
5246 .with_number(crate::CostNumber::PerUnit { value: dec!(100) })
5247 .with_currency("USD"),
5248 ),
5249 BookingMethod::Strict,
5250 )
5251 .expect("drains the first lot");
5252 assert_eq!(inv.positions.slot_count(), 2, "one live lot, one tombstone");
5253
5254 inv.modify_positions(|positions| {
5255 assert_eq!(
5256 positions.len(),
5257 1,
5258 "the closure must see only live lots; tombstones are ours, not \
5259 the caller's",
5260 );
5261 positions.push(Position::simple(Amount::new(dec!(5), "USD")));
5262 });
5263
5264 // Every derived structure now describes what the closure left.
5265 assert_eq!(inv.units("USD"), dec!(5), "units_cache rebuilt");
5266 assert_eq!(inv.units("AAPL"), dec!(20));
5267 assert_eq!(inv.positions.len(), 2);
5268
5269 // simple_index rebuilt: a further cost-less add MERGES.
5270 inv.add(Position::simple(Amount::new(dec!(2), "USD")))
5271 .expect("fits");
5272 assert_eq!(inv.positions.len(), 2, "merged rather than appended");
5273 assert_eq!(inv.units("USD"), dec!(7));
5274
5275 // cost_index rebuilt: the surviving lot is still findable by its cost.
5276 inv.reduce(
5277 &Amount::new(dec!(-20), "AAPL"),
5278 Some(
5279 &CostSpec::empty()
5280 .with_number(crate::CostNumber::PerUnit { value: dec!(200) })
5281 .with_currency("USD"),
5282 ),
5283 BookingMethod::Strict,
5284 )
5285 .expect("the surviving lot is still reachable through the cost index");
5286 assert_eq!(inv.units("AAPL"), dec!(0));
5287 }
5288
5289 /// A shared snapshot must not carry the cost index.
5290 ///
5291 /// `Inventory` derives `Clone`, and BQL clones a shared running balance
5292 /// ONCE PER OUTPUT ROW — the executor says so directly above the call.
5293 /// The shared backing makes the positions O(1) to clone, which is what
5294 /// #1086 needed; a per-inventory map holding roughly an entry per distinct
5295 /// cost would put O(lots) straight back into every one of those clones and
5296 /// undo it.
5297 ///
5298 /// Nothing else in the suite would notice: the index is invisible in
5299 /// results, and no instruction profile here runs BQL. So it is asserted
5300 /// directly, on the representation.
5301 #[test]
5302 fn a_shared_snapshot_carries_no_cost_index() {
5303 let mut shared = Inventory::new_shared();
5304 for units in [dec!(10), dec!(20), dec!(30)] {
5305 shared
5306 .add(Position::with_cost(
5307 Amount::new(units, "AAPL"),
5308 Cost::new(units * dec!(10), "USD"),
5309 ))
5310 .expect("fits");
5311 }
5312 shared.rebuild_index();
5313 assert!(
5314 shared.cost_index.is_empty(),
5315 "a shared snapshot built an index of {} entries; every per-row \
5316 clone now pays for it",
5317 shared.cost_index.len(),
5318 );
5319
5320 // The owned backing — the one that books — still gets it.
5321 let mut owned = Inventory::new();
5322 for units in [dec!(10), dec!(20), dec!(30)] {
5323 owned
5324 .add(Position::with_cost(
5325 Amount::new(units, "AAPL"),
5326 Cost::new(units * dec!(10), "USD"),
5327 ))
5328 .expect("fits");
5329 }
5330 assert_eq!(
5331 owned.cost_index.len(),
5332 3,
5333 "the owned backing must still index its lots, or the fast path is \
5334 dead everywhere",
5335 );
5336
5337 // And a snapshot still books CORRECTLY, by scanning: an inventory with
5338 // no index must never answer "no matching lot" for a lot it holds.
5339 let result = shared
5340 .reduce(
5341 &Amount::new(dec!(-20), "AAPL"),
5342 Some(
5343 &CostSpec::empty()
5344 .with_number(crate::CostNumber::PerUnit { value: dec!(200) })
5345 .with_currency("USD"),
5346 ),
5347 BookingMethod::Strict,
5348 )
5349 .expect("a snapshot with no cost index must fall back to scanning");
5350 assert_eq!(result.matched.len(), 1);
5351 }
5352
5353 /// Tombstones must not reach the wire, and a round trip must come back
5354 /// dense.
5355 ///
5356 /// Every other round-trip test builds its inventory with `add` alone, so
5357 /// none of them has a tombstone in it — the sparse backing was entirely
5358 /// untested through serde. It matters twice over: the wire format is
5359 /// pinned by downstream snapshots, and a leaked `null` would both break
5360 /// them and deserialize into a lot that does not exist.
5361 #[test]
5362 fn a_drained_lot_does_not_reach_the_wire() {
5363 let mut inv = Inventory::new();
5364 for units in [dec!(10), dec!(20)] {
5365 inv.add(Position::with_cost(
5366 Amount::new(units, "AAPL"),
5367 Cost::new(units * dec!(10), "USD"),
5368 ))
5369 .expect("fits");
5370 }
5371 inv.reduce(
5372 &Amount::new(dec!(-10), "AAPL"),
5373 Some(
5374 &CostSpec::empty()
5375 .with_number(crate::CostNumber::PerUnit { value: dec!(100) })
5376 .with_currency("USD"),
5377 ),
5378 BookingMethod::Strict,
5379 )
5380 .expect("drains the first lot");
5381 assert_eq!(
5382 inv.positions.slot_count(),
5383 2,
5384 "the fixture must actually hold a tombstone, or this proves nothing",
5385 );
5386 assert_eq!(inv.positions.len(), 1, "one live lot");
5387
5388 let json = serde_json::to_string(&inv).expect("serializes");
5389 // Check the positions ARRAY, not the whole string: `Cost`'s optional
5390 // `date` and `label` serialize as `null` legitimately, so a bare
5391 // "contains null" search reports a leak that is not there.
5392 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
5393 let wire_positions = parsed
5394 .get("positions")
5395 .and_then(serde_json::Value::as_array)
5396 .expect("positions is an array");
5397 assert_eq!(
5398 wire_positions.len(),
5399 1,
5400 "the wire must carry only the live lot, not the tombstone: {json}",
5401 );
5402 assert!(
5403 !wire_positions.iter().any(serde_json::Value::is_null),
5404 "a tombstone leaked onto the wire as a null element: {json}",
5405 );
5406
5407 let round_tripped: Inventory = serde_json::from_str(&json).expect("deserializes");
5408 assert_eq!(
5409 round_tripped.positions.slot_count(),
5410 1,
5411 "the round trip must come back dense, not carrying the hole",
5412 );
5413 assert_eq!(round_tripped.positions.len(), 1);
5414 assert_eq!(round_tripped.units("AAPL"), dec!(20));
5415 assert_eq!(
5416 round_tripped
5417 .positions()
5418 .next()
5419 .expect("one lot")
5420 .units
5421 .number,
5422 dec!(20),
5423 );
5424
5425 // The rebuilt caches must work: the surviving lot is still bookable.
5426 let mut round_tripped = round_tripped;
5427 round_tripped
5428 .reduce(
5429 &Amount::new(dec!(-20), "AAPL"),
5430 Some(
5431 &CostSpec::empty()
5432 .with_number(crate::CostNumber::PerUnit { value: dec!(200) })
5433 .with_currency("USD"),
5434 ),
5435 BookingMethod::Strict,
5436 )
5437 .expect("the deserialized lot is reachable");
5438 assert_eq!(round_tripped.units("AAPL"), dec!(0));
5439 }
5440}