Skip to main content

rustledger_core/
intern.rs

1//! String interning for accounts and currencies.
2//!
3//! String interning reduces memory usage by storing each unique string once
4//! and using references to that single copy. This is especially useful for
5//! account names and currencies which appear repeatedly throughout a ledger.
6//!
7//! # Example
8//!
9//! ```
10//! use rustledger_core::intern::StringInterner;
11//!
12//! let mut interner = StringInterner::new();
13//!
14//! let s1 = interner.intern("Expenses:Food");
15//! let s2 = interner.intern("Expenses:Food");
16//! let s3 = interner.intern("Assets:Bank");
17//!
18//! // s1 and s2 point to the same string
19//! assert!(std::ptr::eq(s1.as_str().as_ptr(), s2.as_str().as_ptr()));
20//!
21//! // s3 is different
22//! assert!(!std::ptr::eq(s1.as_str().as_ptr(), s3.as_str().as_ptr()));
23//! ```
24
25use rustc_hash::FxHashSet;
26use std::sync::Arc;
27
28use serde::{Deserialize, Deserializer, Serialize, Serializer};
29
30/// An interned string reference.
31///
32/// This is a thin wrapper around `Arc<str>` that provides cheap cloning
33/// and comparison. Two `InternedStr` values with the same content will
34/// share the same underlying memory.
35#[derive(Debug, Clone, Eq)]
36pub struct InternedStr(Arc<str>);
37
38// rkyv support: use AsString wrapper to serialize as String
39#[cfg(feature = "rkyv")]
40pub use rkyv_impl::AsInternedStr;
41
42/// Type alias for rkyv wrapper for `Option<InternedStr>`.
43/// Use: `#[rkyv(with = rkyv::with::Map<AsInternedStr>)]`
44#[cfg(feature = "rkyv")]
45pub type AsOptionInternedStr = rkyv::with::Map<AsInternedStr>;
46
47/// Type alias for rkyv wrapper for `Vec<InternedStr>`.
48/// Use: `#[rkyv(with = rkyv::with::Map<AsInternedStr>)]`
49#[cfg(feature = "rkyv")]
50pub type AsVecInternedStr = rkyv::with::Map<AsInternedStr>;
51
52/// A [`StringInterner`] installed for the duration of a deserialization.
53///
54/// `InternedStr` fields built while it is in scope share one `Arc<str>` per
55/// distinct string, rather than being deduplicated by a second pass
56/// afterwards.
57///
58/// The cache-hit path used to do both halves of that: rkyv handed every
59/// occurrence its own fresh `Arc` (40,015 of them for a ledger with a few
60/// dozen distinct strings), and `reintern_directives` then walked every
61/// directive again to collapse them. Interning on the way in makes the walk
62/// unnecessary — it establishes exactly the postcondition that pass exists to
63/// guarantee, that equal strings share a pointer.
64///
65/// Scoped rather than process-wide on purpose: an interner that outlives the
66/// call would accumulate every distinct payee and narration a long-running
67/// LSP or FFI host had ever seen, and this crate already offers explicit
68/// [`StringInterner`] / [`AccountInterner`] for callers that want to own one.
69/// The table here is dropped with the guard; the `Arc`s it handed out live on
70/// in the deserialized values, which is the whole point.
71///
72/// Nesting is safe: an inner scope observes that one is already installed,
73/// leaves it in place, and is a no-op on drop, so the outer scope keeps
74/// interning through to its own end.
75#[cfg(feature = "rkyv")]
76#[must_use = "the interner is uninstalled as soon as the guard drops"]
77pub struct InternScope {
78    /// Whether THIS guard installed the interner and so must remove it.
79    installed: bool,
80}
81
82#[cfg(feature = "rkyv")]
83thread_local! {
84    static SCOPED_INTERNER: std::cell::RefCell<Option<StringInterner>> =
85        const { std::cell::RefCell::new(None) };
86}
87
88#[cfg(feature = "rkyv")]
89impl InternScope {
90    /// Install an interner for this thread until the returned guard drops.
91    pub fn new() -> Self {
92        let installed = SCOPED_INTERNER.with(|cell| {
93            let mut slot = cell.borrow_mut();
94            if slot.is_some() {
95                false
96            } else {
97                *slot = Some(StringInterner::with_capacity(1024));
98                true
99            }
100        });
101        Self { installed }
102    }
103}
104
105#[cfg(feature = "rkyv")]
106impl Default for InternScope {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112#[cfg(feature = "rkyv")]
113impl Drop for InternScope {
114    fn drop(&mut self) {
115        if self.installed {
116            SCOPED_INTERNER.with(|cell| {
117                *cell.borrow_mut() = None;
118            });
119        }
120    }
121}
122
123/// Intern `s` through the active [`InternScope`], or `None` if none is
124/// installed.
125#[cfg(feature = "rkyv")]
126fn intern_scoped(s: &str) -> Option<InternedStr> {
127    SCOPED_INTERNER.with(|cell| {
128        // `try_borrow_mut` rather than `borrow_mut`: interning must never
129        // panic on a path that is only an optimization. A reentrant call
130        // cannot happen through `deserialize_with` today, and if one ever
131        // does it falls back to an un-shared `Arc` instead of aborting a
132        // load.
133        cell.try_borrow_mut()
134            .ok()?
135            .as_mut()
136            .map(|interner| interner.intern(s))
137    })
138}
139
140#[cfg(feature = "rkyv")]
141mod rkyv_impl {
142    use super::InternedStr;
143    use rkyv::Place;
144    use rkyv::rancor::Fallible;
145    use rkyv::string::ArchivedString;
146    use rkyv::with::{ArchiveWith, DeserializeWith, SerializeWith};
147
148    /// Wrapper to serialize `InternedStr` as String with rkyv.
149    /// Use with `#[rkyv(with = AsInternedStr)]` on `InternedStr` fields.
150    pub struct AsInternedStr;
151
152    impl ArchiveWith<InternedStr> for AsInternedStr {
153        type Archived = ArchivedString;
154        type Resolver = rkyv::string::StringResolver;
155
156        fn resolve_with(field: &InternedStr, resolver: Self::Resolver, out: Place<Self::Archived>) {
157            ArchivedString::resolve_from_str(field.as_str(), resolver, out);
158        }
159    }
160
161    impl<S> SerializeWith<InternedStr, S> for AsInternedStr
162    where
163        S: Fallible + rkyv::ser::Writer + rkyv::ser::Allocator + ?Sized,
164        S::Error: rkyv::rancor::Source,
165    {
166        fn serialize_with(
167            field: &InternedStr,
168            serializer: &mut S,
169        ) -> Result<Self::Resolver, S::Error> {
170            ArchivedString::serialize_from_str(field.as_str(), serializer)
171        }
172    }
173
174    impl<D> DeserializeWith<ArchivedString, InternedStr, D> for AsInternedStr
175    where
176        D: Fallible + ?Sized,
177    {
178        fn deserialize_with(
179            field: &ArchivedString,
180            _deserializer: &mut D,
181        ) -> Result<InternedStr, D::Error> {
182            // rkyv's deserializer carries no interner, so the sharing comes
183            // from an `InternScope` the caller installed. Without one this is
184            // the old behavior: a fresh `Arc` per occurrence.
185            let s = field.as_str();
186            Ok(super::intern_scoped(s).unwrap_or_else(|| InternedStr::new(s)))
187        }
188    }
189}
190
191impl Serialize for InternedStr {
192    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
193        self.0.serialize(serializer)
194    }
195}
196
197impl<'de> Deserialize<'de> for InternedStr {
198    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
199        let s = String::deserialize(deserializer)?;
200        Ok(Self::new(s))
201    }
202}
203
204impl PartialOrd for InternedStr {
205    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
206        Some(self.cmp(other))
207    }
208}
209
210impl Ord for InternedStr {
211    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
212        self.0.cmp(&other.0)
213    }
214}
215
216impl InternedStr {
217    /// Create a new interned string (without using an interner).
218    /// Prefer using `StringInterner::intern` for deduplication.
219    pub fn new(s: impl Into<Arc<str>>) -> Self {
220        Self(s.into())
221    }
222
223    /// Get the string slice.
224    pub fn as_str(&self) -> &str {
225        &self.0
226    }
227
228    /// Check if two interned strings share the same allocation.
229    /// This is O(1) pointer comparison.
230    pub fn ptr_eq(&self, other: &Self) -> bool {
231        Arc::ptr_eq(&self.0, &other.0)
232    }
233}
234
235impl PartialEq for InternedStr {
236    fn eq(&self, other: &Self) -> bool {
237        // Fast path: pointer comparison
238        if Arc::ptr_eq(&self.0, &other.0) {
239            return true;
240        }
241        // Slow path: string comparison
242        self.0 == other.0
243    }
244}
245
246impl std::hash::Hash for InternedStr {
247    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
248        self.0.hash(state);
249    }
250}
251
252impl std::fmt::Display for InternedStr {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        write!(f, "{}", self.0)
255    }
256}
257
258impl AsRef<str> for InternedStr {
259    fn as_ref(&self) -> &str {
260        &self.0
261    }
262}
263
264impl std::ops::Deref for InternedStr {
265    type Target = str;
266
267    fn deref(&self) -> &Self::Target {
268        &self.0
269    }
270}
271
272impl From<&str> for InternedStr {
273    fn from(s: &str) -> Self {
274        Self::new(s)
275    }
276}
277
278impl From<String> for InternedStr {
279    fn from(s: String) -> Self {
280        Self::new(s)
281    }
282}
283
284impl From<&String> for InternedStr {
285    fn from(s: &String) -> Self {
286        Self::new(s.as_str())
287    }
288}
289
290impl From<&Self> for InternedStr {
291    fn from(s: &Self) -> Self {
292        s.clone()
293    }
294}
295
296impl PartialEq<str> for InternedStr {
297    fn eq(&self, other: &str) -> bool {
298        self.as_str() == other
299    }
300}
301
302impl PartialEq<&str> for InternedStr {
303    fn eq(&self, other: &&str) -> bool {
304        self.as_str() == *other
305    }
306}
307
308impl PartialEq<String> for InternedStr {
309    fn eq(&self, other: &String) -> bool {
310        self.as_str() == other
311    }
312}
313
314impl Default for InternedStr {
315    fn default() -> Self {
316        Self::new("")
317    }
318}
319
320impl std::borrow::Borrow<str> for InternedStr {
321    fn borrow(&self) -> &str {
322        self.as_str()
323    }
324}
325
326/// A string interner that deduplicates strings.
327///
328/// This is useful for reducing memory usage when many strings with the
329/// same content are created, such as account names and currencies in
330/// a large ledger.
331#[derive(Debug, Default)]
332pub struct StringInterner {
333    /// Set of all interned strings.
334    strings: FxHashSet<Arc<str>>,
335}
336
337impl StringInterner {
338    /// Create a new empty interner.
339    pub fn new() -> Self {
340        Self {
341            strings: FxHashSet::default(),
342        }
343    }
344
345    /// Create an interner with pre-allocated capacity.
346    pub fn with_capacity(capacity: usize) -> Self {
347        Self {
348            strings: FxHashSet::with_capacity_and_hasher(capacity, Default::default()),
349        }
350    }
351
352    /// Intern a string.
353    ///
354    /// If the string already exists in the interner, returns a reference
355    /// to the existing copy. Otherwise, stores the string and returns
356    /// a reference to the new copy.
357    pub fn intern(&mut self, s: &str) -> InternedStr {
358        self.intern_with_status(s).0
359    }
360
361    /// Intern a string, also returning whether it was newly inserted.
362    ///
363    /// Equivalent to [`Self::intern`] but exposes the insertion bit
364    /// without a second hash lookup. Useful for dedup-counting passes
365    /// (see `rustledger_loader::dedup`) that previously called
366    /// `contains` then `intern` — a redundant double lookup. Returns
367    /// `(interned, was_new)`.
368    pub fn intern_with_status(&mut self, s: &str) -> (InternedStr, bool) {
369        if let Some(existing) = self.strings.get(s) {
370            (InternedStr(existing.clone()), false)
371        } else {
372            let arc: Arc<str> = s.into();
373            self.strings.insert(arc.clone());
374            (InternedStr(arc), true)
375        }
376    }
377
378    /// Intern a string, taking ownership.
379    pub fn intern_string(&mut self, s: String) -> InternedStr {
380        if let Some(existing) = self.strings.get(s.as_str()) {
381            InternedStr(existing.clone())
382        } else {
383            let arc: Arc<str> = s.into();
384            self.strings.insert(arc.clone());
385            InternedStr(arc)
386        }
387    }
388
389    /// Check if a string is already interned.
390    pub fn contains(&self, s: &str) -> bool {
391        self.strings.contains(s)
392    }
393
394    /// Get the number of unique strings.
395    pub fn len(&self) -> usize {
396        self.strings.len()
397    }
398
399    /// Check if the interner is empty.
400    pub fn is_empty(&self) -> bool {
401        self.strings.is_empty()
402    }
403
404    /// Get an iterator over all interned strings.
405    pub fn iter(&self) -> impl Iterator<Item = &str> {
406        self.strings.iter().map(std::convert::AsRef::as_ref)
407    }
408
409    /// Clear all interned strings.
410    pub fn clear(&mut self) {
411        self.strings.clear();
412    }
413}
414
415/// A specialized interner for account names.
416///
417/// Account names follow a specific pattern (Type:Component:Component)
418/// and this interner can provide additional functionality like
419/// extracting components.
420#[derive(Debug, Default)]
421pub struct AccountInterner {
422    interner: StringInterner,
423}
424
425impl AccountInterner {
426    /// Create a new account interner.
427    pub fn new() -> Self {
428        Self {
429            interner: StringInterner::new(),
430        }
431    }
432
433    /// Intern an account name.
434    pub fn intern(&mut self, account: &str) -> InternedStr {
435        self.interner.intern(account)
436    }
437
438    /// Get the number of unique accounts.
439    pub fn len(&self) -> usize {
440        self.interner.len()
441    }
442
443    /// Check if empty.
444    pub fn is_empty(&self) -> bool {
445        self.interner.is_empty()
446    }
447
448    /// Get all interned accounts.
449    pub fn accounts(&self) -> impl Iterator<Item = &str> {
450        self.interner.iter()
451    }
452
453    /// Get accounts matching a prefix.
454    pub fn accounts_with_prefix<'a>(&'a self, prefix: &'a str) -> impl Iterator<Item = &'a str> {
455        self.interner.iter().filter(move |s| s.starts_with(prefix))
456    }
457}
458
459/// A specialized interner for currency codes.
460///
461/// Currency codes are typically short (3-4 characters) and uppercase.
462#[derive(Debug, Default)]
463pub struct CurrencyInterner {
464    interner: StringInterner,
465}
466
467impl CurrencyInterner {
468    /// Create a new currency interner.
469    pub fn new() -> Self {
470        Self {
471            interner: StringInterner::new(),
472        }
473    }
474
475    /// Intern a currency code.
476    pub fn intern(&mut self, currency: &str) -> InternedStr {
477        self.interner.intern(currency)
478    }
479
480    /// Get the number of unique currencies.
481    pub fn len(&self) -> usize {
482        self.interner.len()
483    }
484
485    /// Check if empty.
486    pub fn is_empty(&self) -> bool {
487        self.interner.is_empty()
488    }
489
490    /// Get all interned currencies.
491    pub fn currencies(&self) -> impl Iterator<Item = &str> {
492        self.interner.iter()
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn test_interned_str_equality() {
502        let s1 = InternedStr::new("hello");
503        let s2 = InternedStr::new("hello");
504        let s3 = InternedStr::new("world");
505
506        assert_eq!(s1, s2);
507        assert_ne!(s1, s3);
508        assert_eq!(s1, "hello");
509        assert_eq!(s1, "hello".to_string());
510    }
511
512    #[test]
513    fn test_interner_deduplication() {
514        let mut interner = StringInterner::new();
515
516        let s1 = interner.intern("Expenses:Food");
517        let s2 = interner.intern("Expenses:Food");
518        let s3 = interner.intern("Assets:Bank");
519
520        // s1 and s2 should share the same allocation
521        assert!(s1.ptr_eq(&s2));
522
523        // s3 is different
524        assert!(!s1.ptr_eq(&s3));
525
526        // Only 2 unique strings
527        assert_eq!(interner.len(), 2);
528    }
529
530    #[test]
531    fn test_interner_contains() {
532        let mut interner = StringInterner::new();
533
534        interner.intern("hello");
535
536        assert!(interner.contains("hello"));
537        assert!(!interner.contains("world"));
538    }
539
540    #[test]
541    fn test_account_interner() {
542        let mut interner = AccountInterner::new();
543
544        interner.intern("Expenses:Food:Coffee");
545        interner.intern("Expenses:Food:Groceries");
546        interner.intern("Assets:Bank:Checking");
547
548        assert_eq!(interner.len(), 3);
549
550        assert_eq!(interner.accounts_with_prefix("Expenses:").count(), 2);
551    }
552
553    #[test]
554    fn test_currency_interner() {
555        let mut interner = CurrencyInterner::new();
556
557        let usd1 = interner.intern("USD");
558        let usd2 = interner.intern("USD");
559        let eur = interner.intern("EUR");
560
561        assert!(usd1.ptr_eq(&usd2));
562        assert!(!usd1.ptr_eq(&eur));
563        assert_eq!(interner.len(), 2);
564    }
565
566    #[test]
567    fn test_interned_str_hash() {
568        use std::collections::HashMap;
569
570        let s1 = InternedStr::new("key");
571        let s2 = InternedStr::new("key");
572
573        let mut map = HashMap::new();
574        map.insert(s1, 1);
575
576        // s2 should find the same entry as s1
577        assert_eq!(map.get(&s2), Some(&1));
578    }
579}
580
581// rkyv wrapper for rust_decimal::Decimal - serialize as fixed 16 bytes
582#[cfg(feature = "rkyv")]
583pub use rkyv_decimal::AsDecimal;
584
585#[cfg(feature = "rkyv")]
586mod rkyv_decimal {
587    use rkyv::Place;
588    use rkyv::rancor::Fallible;
589    use rkyv::with::{ArchiveWith, DeserializeWith, SerializeWith};
590    use rust_decimal::Decimal;
591
592    /// Wrapper to serialize `Decimal` as fixed 16-byte binary with rkyv.
593    /// This is more compact and faster than string serialization.
594    pub struct AsDecimal;
595
596    impl ArchiveWith<Decimal> for AsDecimal {
597        type Archived = [u8; 16];
598        type Resolver = [(); 16];
599
600        fn resolve_with(field: &Decimal, resolver: Self::Resolver, out: Place<Self::Archived>) {
601            let bytes = field.serialize();
602            // Use rkyv's Archive impl for [u8; 16] which handles this safely
603            rkyv::Archive::resolve(&bytes, resolver, out);
604        }
605    }
606
607    impl<S> SerializeWith<Decimal, S> for AsDecimal
608    where
609        S: Fallible + ?Sized,
610    {
611        fn serialize_with(
612            _field: &Decimal,
613            _serializer: &mut S,
614        ) -> Result<Self::Resolver, S::Error> {
615            // No extra serialization needed - data is inlined
616            Ok([(); 16])
617        }
618    }
619
620    impl<D> DeserializeWith<[u8; 16], Decimal, D> for AsDecimal
621    where
622        D: Fallible + ?Sized,
623    {
624        fn deserialize_with(field: &[u8; 16], _deserializer: &mut D) -> Result<Decimal, D::Error> {
625            Ok(Decimal::deserialize(*field))
626        }
627    }
628}
629
630// rkyv wrapper for jiff::civil::Date (re-exported as NaiveDate) - serialize
631// as i32 (days since Unix epoch). 4 bytes instead of 10+ for ISO string.
632#[cfg(feature = "rkyv")]
633pub use rkyv_date::AsNaiveDate;
634
635#[cfg(feature = "rkyv")]
636mod rkyv_date {
637    use crate::NaiveDate;
638    use rkyv::Place;
639    use rkyv::rancor::Fallible;
640    use rkyv::with::{ArchiveWith, DeserializeWith, SerializeWith};
641
642    /// Wrapper to serialize `NaiveDate` as i32 (days since Unix epoch) with rkyv.
643    /// This is 4 bytes instead of 10+ for string, and faster to serialize.
644    pub struct AsNaiveDate;
645
646    const UNIX_EPOCH: NaiveDate = jiff::civil::date(1970, 1, 1);
647
648    impl ArchiveWith<NaiveDate> for AsNaiveDate {
649        type Archived = rkyv::Archived<i32>;
650        type Resolver = ();
651
652        fn resolve_with(field: &NaiveDate, _resolver: Self::Resolver, out: Place<Self::Archived>) {
653            let days = field.since(UNIX_EPOCH).unwrap_or_default().get_days();
654            rkyv::Archive::resolve(&days, (), out);
655        }
656    }
657
658    impl<S> SerializeWith<NaiveDate, S> for AsNaiveDate
659    where
660        S: Fallible + ?Sized,
661    {
662        fn serialize_with(
663            _field: &NaiveDate,
664            _serializer: &mut S,
665        ) -> Result<Self::Resolver, S::Error> {
666            Ok(())
667        }
668    }
669
670    impl<D> DeserializeWith<rkyv::Archived<i32>, NaiveDate, D> for AsNaiveDate
671    where
672        D: Fallible + ?Sized,
673    {
674        fn deserialize_with(
675            field: &rkyv::Archived<i32>,
676            _deserializer: &mut D,
677        ) -> Result<NaiveDate, D::Error> {
678            let days = field.to_native();
679            Ok(UNIX_EPOCH
680                .checked_add(jiff::Span::new().days(i64::from(days)))
681                .expect("valid date"))
682        }
683    }
684}