Skip to main content

nanny_runtime/
ledger.rs

1// FakeLedger — in-memory ledger for local mode.
2//
3// Fake money. Real stops.
4//
5// The numbers mean nothing outside this process.
6// The enforcement is identical to what a real ledger will provide.
7// When AP2 arrives, this implementation is swapped out.
8// The executor, policy, and event log do not change at all.
9
10use nanny_core::ledger::{Ledger, LedgerDecision, LedgerError, Receipt};
11
12// ── FakeLedger ────────────────────────────────────────────────────────────────
13
14/// In-memory ledger for local mode execution.
15///
16/// Initialized with a balance derived from `nanny.toml → limits.tokens`.
17/// Every debit reduces the balance. When balance hits zero, the policy stops
18/// execution via `BudgetExhausted`.
19///
20/// No persistence. No network. No real money.
21/// The receipts are real records — they just don't move actual funds.
22pub struct FakeLedger {
23    /// Current unspent balance.
24    balance: u64,
25
26    /// Running total of all tokens spent so far.
27    total_spent: u64,
28}
29
30impl FakeLedger {
31    /// Create a new FakeLedger with the given initial balance.
32    ///
33    /// Pass `limits.max_tokens` from your nanny.toml here.
34    /// That makes the budget limit and the ledger balance consistent.
35    pub fn new(initial_balance: u64) -> Self {
36        Self {
37            balance: initial_balance,
38            total_spent: 0,
39        }
40    }
41}
42
43impl Ledger for FakeLedger {
44    /// Check whether a spend is possible without committing to it.
45    ///
46    /// Called by the executor to build PolicyContext.
47    /// Does not change the balance.
48    fn authorize(&self, amount: u64) -> LedgerDecision {
49        if amount <= self.balance {
50            LedgerDecision::Approved
51        } else {
52            LedgerDecision::InsufficientFunds {
53                available: self.balance,
54                requested: amount,
55            }
56        }
57    }
58
59    /// Spend `amount` units and return a receipt.
60    ///
61    /// Reduces the balance permanently.
62    /// Returns an error if the balance is insufficient.
63    fn debit(&mut self, amount: u64) -> Result<Receipt, LedgerError> {
64        if amount > self.balance {
65            return Err(LedgerError::InsufficientFunds {
66                requested: amount,
67                available: self.balance,
68            });
69        }
70
71        self.balance -= amount;
72        self.total_spent += amount;
73
74        Ok(Receipt {
75            amount,
76            balance_after: self.balance,
77        })
78    }
79
80    /// Current unspent balance.
81    fn balance(&self) -> u64 {
82        self.balance
83    }
84
85    /// Total tokens spent across all debits.
86    fn total_spent(&self) -> u64 {
87        self.total_spent
88    }
89}
90
91// ── Tests ─────────────────────────────────────────────────────────────────────
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn starts_with_full_balance() {
99        let ledger = FakeLedger::new(100);
100        assert_eq!(ledger.balance(), 100);
101        assert_eq!(ledger.total_spent(), 0);
102    }
103
104    #[test]
105    fn debit_reduces_balance() {
106        let mut ledger = FakeLedger::new(100);
107        let receipt = ledger.debit(30).unwrap();
108
109        assert_eq!(receipt.amount, 30);
110        assert_eq!(receipt.balance_after, 70);
111        assert_eq!(ledger.balance(), 70);
112        assert_eq!(ledger.total_spent(), 30);
113    }
114
115    #[test]
116    fn multiple_debits_accumulate() {
117        let mut ledger = FakeLedger::new(100);
118        ledger.debit(10).unwrap();
119        ledger.debit(20).unwrap();
120        ledger.debit(30).unwrap();
121
122        assert_eq!(ledger.balance(), 40);
123        assert_eq!(ledger.total_spent(), 60);
124    }
125
126    #[test]
127    fn debit_exact_balance_succeeds() {
128        let mut ledger = FakeLedger::new(50);
129        let receipt = ledger.debit(50).unwrap();
130
131        assert_eq!(receipt.balance_after, 0);
132        assert_eq!(ledger.balance(), 0);
133    }
134
135    #[test]
136    fn debit_over_balance_fails() {
137        let mut ledger = FakeLedger::new(10);
138        let result = ledger.debit(11);
139
140        assert!(result.is_err());
141        // Balance must be unchanged after a failed debit.
142        assert_eq!(ledger.balance(), 10);
143        assert_eq!(ledger.total_spent(), 0);
144    }
145
146    #[test]
147    fn authorize_approves_within_balance() {
148        let ledger = FakeLedger::new(100);
149        assert!(matches!(ledger.authorize(100), LedgerDecision::Approved));
150    }
151
152    #[test]
153    fn authorize_denies_over_balance() {
154        let ledger = FakeLedger::new(10);
155        assert!(matches!(
156            ledger.authorize(11),
157            LedgerDecision::InsufficientFunds { available: 10, requested: 11 }
158        ));
159    }
160
161    #[test]
162    fn failed_debit_does_not_change_total_debited() {
163        let mut ledger = FakeLedger::new(5);
164        ledger.debit(3).unwrap();
165        let _ = ledger.debit(10); // fails
166
167        assert_eq!(ledger.total_spent(), 3); // only the successful debit counts
168        assert_eq!(ledger.balance(), 2);
169    }
170}