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#[derive(Debug, Clone, PartialOrd, Ord, PartialEq, Eq, Hash)]
38pub struct Account(Arc<str>);
39
40impl Account {
41 #[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#[derive(Debug, Clone, PartialEq)]
93#[non_exhaustive]
94pub struct Open {
95 pub account: Account,
97 pub currencies: HashSet<Currency>,
99 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#[derive(Debug, Clone, PartialEq)]
141#[non_exhaustive]
142pub struct Close {
143 pub account: Account,
145}
146
147#[derive(Debug, Clone, PartialEq)]
160#[non_exhaustive]
161pub struct Balance<D> {
162 pub account: Account,
164 pub amount: Amount<D>,
166 pub tolerance: Option<D>,
170}
171
172#[derive(Debug, Clone, PartialEq)]
184#[non_exhaustive]
185pub struct Pad {
186 pub account: Account,
188 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}