beancount_parser/
account.rs

1use std::{
2    borrow::Borrow,
3    collections::HashSet,
4    fmt::{Display, Formatter},
5    str::FromStr,
6    sync::Arc,
7};
8
9use nom::{
10    bytes::complete::take_while,
11    character::complete::{char, satisfy, space0, space1},
12    combinator::{all_consuming, cut, iterator, opt, recognize},
13    multi::many1_count,
14    sequence::{delimited, preceded},
15    Finish,
16};
17
18use crate::{
19    amount::{self, Amount, Currency},
20    Decimal, Span,
21};
22
23use super::IResult;
24
25/// Account
26///
27/// You may convert it into a string slice with [`Account::as_str`]
28///
29/// # Example
30/// ```
31/// use beancount_parser::{BeancountFile, DirectiveContent};
32/// let input = "2022-05-24 open Assets:Bank:Checking";
33/// let beancount: BeancountFile<f64> = input.parse().unwrap();
34/// let DirectiveContent::Open(open) = &beancount.directives[0].content else { unreachable!() };
35/// assert_eq!(open.account.as_str(), "Assets:Bank:Checking");
36/// ```
37#[derive(Debug, Clone, PartialOrd, Ord, PartialEq, Eq, Hash)]
38pub struct Account(Arc<str>);
39
40impl Account {
41    /// Returns underlying string representation
42    #[must_use]
43    pub fn as_str(&self) -> &str {
44        &self.0
45    }
46}
47
48impl Display for Account {
49    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
50        Display::fmt(&self.0, f)
51    }
52}
53
54impl AsRef<str> for Account {
55    fn as_ref(&self) -> &str {
56        self.0.as_ref()
57    }
58}
59
60impl Borrow<str> for Account {
61    fn borrow(&self) -> &str {
62        self.0.borrow()
63    }
64}
65
66impl FromStr for Account {
67    type Err = crate::Error;
68
69    fn from_str(input: &str) -> Result<Self, Self::Err> {
70        let spanned = Span::new(input);
71        match all_consuming(parse)(spanned).finish() {
72            Ok((_, account)) => Ok(account),
73            Err(err) => {
74                println!("{err:?}");
75                Err(Self::Err::new(input, spanned))
76            }
77        }
78    }
79}
80
81/// Open account directive
82///
83/// # Example
84/// ```
85/// use beancount_parser::{BeancountFile, DirectiveContent};
86/// let input = "2022-05-24 open Assets:Bank:Checking    CHF";
87/// let beancount: BeancountFile<f64> = input.parse().unwrap();
88/// let DirectiveContent::Open(open) = &beancount.directives[0].content else { unreachable!() };
89/// assert_eq!(open.account.as_str(), "Assets:Bank:Checking");
90/// assert_eq!(open.currencies.iter().next().unwrap().as_str(), "CHF");
91/// ```
92#[derive(Debug, Clone, PartialEq)]
93#[non_exhaustive]
94pub struct Open {
95    /// Account being open
96    pub account: Account,
97    /// Currency constraints
98    pub currencies: HashSet<Currency>,
99    /// Booking method
100    pub booking_method: Option<BookingMethod>,
101}
102
103#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
104pub struct BookingMethod(Arc<str>);
105
106impl AsRef<str> for BookingMethod {
107    fn as_ref(&self) -> &str {
108        &self.0
109    }
110}
111
112impl Borrow<str> for BookingMethod {
113    fn borrow(&self) -> &str {
114        self.0.borrow()
115    }
116}
117
118impl Display for BookingMethod {
119    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
120        Display::fmt(&self.0, f)
121    }
122}
123
124impl From<&str> for BookingMethod {
125    fn from(value: &str) -> Self {
126        Self(Arc::from(value))
127    }
128}
129
130/// Close account directive
131///
132/// # Example
133/// ```
134/// use beancount_parser::{BeancountFile, DirectiveContent};
135/// let input = "2022-05-24 close Assets:Bank:Checking";
136/// let beancount: BeancountFile<f64> = input.parse().unwrap();
137/// let DirectiveContent::Close(close) = &beancount.directives[0].content else { unreachable!() };
138/// assert_eq!(close.account.as_str(), "Assets:Bank:Checking");
139/// ```
140#[derive(Debug, Clone, PartialEq)]
141#[non_exhaustive]
142pub struct Close {
143    /// Account being closed
144    pub account: Account,
145}
146
147/// Balance assertion
148///
149/// # Example
150/// ```
151/// use beancount_parser::{BeancountFile, DirectiveContent};
152/// let input = "2022-05-24 balance Assets:Bank:Checking 10 CHF";
153/// let beancount: BeancountFile<f64> = input.parse().unwrap();
154/// let DirectiveContent::Balance(balance) = &beancount.directives[0].content else { unreachable!() };
155/// assert_eq!(balance.account.as_str(), "Assets:Bank:Checking");
156/// assert_eq!(balance.amount.value, 10.0);
157/// assert_eq!(balance.amount.currency.as_str(), "CHF");
158/// ```
159#[derive(Debug, Clone, PartialEq)]
160#[non_exhaustive]
161pub struct Balance<D> {
162    /// Account being asserted
163    pub account: Account,
164    /// Amount the amount should have on the date
165    pub amount: Amount<D>,
166    /// Explicit precision tolerance
167    ///
168    /// See: <https://beancount.github.io/docs/precision_tolerances.html#explicit-tolerances-on-balance-assertions>
169    pub tolerance: Option<D>,
170}
171
172/// Pad directive
173///
174/// # Example
175/// ```
176/// # use beancount_parser::{BeancountFile, DirectiveContent};
177/// let raw = "2014-06-01 pad Assets:BofA:Checking Equity:Opening-Balances";
178/// let file: BeancountFile<f64> = raw.parse().unwrap();
179/// let DirectiveContent::Pad(pad) = &file.directives[0].content else { unreachable!() };
180/// assert_eq!(pad.account.as_str(), "Assets:BofA:Checking");
181/// assert_eq!(pad.source_account.as_str(), "Equity:Opening-Balances");
182/// ```
183#[derive(Debug, Clone, PartialEq)]
184#[non_exhaustive]
185pub struct Pad {
186    /// Account being padded
187    pub account: Account,
188    /// Source account from which take the money
189    pub source_account: Account,
190}
191
192pub(super) fn parse(input: Span<'_>) -> IResult<'_, Account> {
193    let (input, name) = recognize(preceded(
194        preceded(
195            satisfy(|c: char| c.is_uppercase() || c.is_ascii_digit()),
196            take_while(|c: char| c.is_alphanumeric() || c == '-'),
197        ),
198        cut(many1_count(preceded(
199            char(':'),
200            preceded(
201                satisfy(|c: char| c.is_uppercase() || c.is_ascii_digit()),
202                take_while(|c: char| c.is_alphanumeric() || c == '-'),
203            ),
204        ))),
205    ))(input)?;
206    Ok((input, Account(Arc::from(*name.fragment()))))
207}
208
209pub(super) fn open(input: Span<'_>) -> IResult<'_, Open> {
210    let (input, account) = parse(input)?;
211    let (input, currencies) = opt(preceded(space1, currencies))(input)?;
212    let (input, booking_method) = opt(preceded(space1, crate::string))(input)?;
213    Ok((
214        input,
215        Open {
216            account,
217            currencies: currencies.unwrap_or_default(),
218            booking_method: booking_method.map(|s| s.as_str().into()),
219        },
220    ))
221}
222
223fn currencies(input: Span<'_>) -> IResult<'_, HashSet<Currency>> {
224    let (input, first) = amount::currency(input)?;
225    let sep = delimited(space0, char(','), space0);
226    let mut iter = iterator(input, preceded(sep, amount::currency));
227    let mut currencies = HashSet::new();
228    currencies.insert(first);
229    currencies.extend(&mut iter);
230    let (input, ()) = iter.finish()?;
231    Ok((input, currencies))
232}
233
234pub(super) fn close(input: Span<'_>) -> IResult<'_, Close> {
235    let (input, account) = parse(input)?;
236    Ok((input, Close { account }))
237}
238
239pub(super) fn balance<D: Decimal>(input: Span<'_>) -> IResult<'_, Balance<D>> {
240    let (input, account) = parse(input)?;
241    let (input, _) = space1(input)?;
242    let (input, value) = amount::expression(input)?;
243    let (input, tolerance) = opt(preceded(space0, tolerance))(input)?;
244    let (input, _) = space1(input)?;
245    let (input, currency) = amount::currency(input)?;
246    Ok((
247        input,
248        Balance {
249            account,
250            amount: Amount { value, currency },
251            tolerance,
252        },
253    ))
254}
255
256fn tolerance<D: Decimal>(input: Span<'_>) -> IResult<'_, D> {
257    let (input, _) = char('~')(input)?;
258    let (input, _) = space0(input)?;
259    let (input, tolerance) = amount::expression(input)?;
260    Ok((input, tolerance))
261}
262
263pub(super) fn pad(input: Span<'_>) -> IResult<'_, Pad> {
264    let (input, account) = parse(input)?;
265    let (input, _) = space1(input)?;
266    let (input, source_account) = parse(input)?;
267    Ok((
268        input,
269        Pad {
270            account,
271            source_account,
272        },
273    ))
274}
275
276#[cfg(test)]
277pub(crate) mod chumksy {
278    use crate::ChumskyParser;
279
280    use super::Account;
281    use chumsky::prelude::*;
282
283    pub(crate) fn account() -> impl ChumskyParser<Account> {
284        let category = choice((
285            just("Assets"),
286            just("Liabilities"),
287            just("Equity"),
288            just("Income"),
289            just("Expenses"),
290        ));
291        let component = filter(|c: &char| c.is_alphanumeric() || *c == '-')
292            .repeated()
293            .at_least(1);
294        category
295            .map(ToOwned::to_owned)
296            .then(just(':').ignore_then(component).repeated().at_least(1))
297            .foldl(|mut account, component| {
298                account.push(':');
299                account.extend(component);
300                account
301            })
302            .map(|s| Account(s.into()))
303            .labelled("account")
304    }
305
306    #[cfg(test)]
307    mod tests {
308        use super::*;
309        use rstest::rstest;
310
311        #[rstest]
312        #[case::assets("Assets:A")]
313        #[case::liabilities("Liabilities:A")]
314        #[case::equity("Equity:A")]
315        #[case::expenses("Expenses:A")]
316        #[case::income("Income:A")]
317        #[case::one_component("Assets:Cash")]
318        #[case::multiple_components("Assets:Cash:Wallet")]
319        #[case::dash("Assets:Hello-world")]
320        #[case::num_at_end("Assets:Cash2")]
321        #[case::num_at_start("Assets:2Cash")]
322        fn should_parse_valid_account(#[case] input: &str) {
323            let account: Account = account().then_ignore(end()).parse(input).unwrap();
324            assert_eq!(account.as_str(), input);
325        }
326
327        #[rstest]
328        #[case("Hello")]
329        #[case("Assets")]
330        #[case("Assets:")]
331        #[case("Assets::A")]
332        fn should_not_parse_invalid_account(#[case] input: &str) {
333            let result = account().then_ignore(end()).parse(input);
334            assert!(result.is_err(), "{result:?}");
335        }
336    }
337}