├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── README.md ├── UNLICENSE.txt └── src ├── lib.rs ├── model.rs ├── parser.rs └── serializer.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [7.0.0] - 2024-06-06 4 | 5 | - Payees/descriptions are optional (improves Ledger compatiblity, thanks to Clayton Carter) 6 | - Support custom date formats during serialization (thanks to Clayton Carter) 7 | - Support metadata tags during serialization (thanks to Clayton Carter) 8 | - Support single-line posting comments at the of the posting line during serialization (thanks to Clayton Carter) 9 | 10 | ## [6.0.0] - 2024-02-26 11 | 12 | - Support for dates and metadata tags in posting comments (thanks to Tim Bates) 13 | - Add FromStr impl for Ledger struct 14 | 15 | ## [5.1.1] - 2022-04-21 16 | 17 | - Fix no indent if only balance (thanks to Cory Forsstrom) 18 | 19 | ## [5.1.0] - 2022-01-23 20 | 21 | - Make SerializerSettings fields public 22 | 23 | ## [5.0.0] - 2022-01-22 24 | 25 | - Implement posting commodity and lot prices 26 | 27 | ## [4.1.0] - 2022-01-18 28 | 29 | ### Changes 30 | 31 | - Rewrite parser to use nom v7 (thanks to Tim Bates) 32 | - Require hard separator between payee name and transaction inline comment (thanks to Tim Bates) 33 | 34 | ## [4.0.0] - 2022-01-04 35 | 36 | ### Added 37 | 38 | - Support for 'include' directives (thanks to Tim Bates) 39 | - Support for virtual postings (thanks to Tim Bates) 40 | - Configurable end of line characters for Serializer 41 | 42 | ### Changed 43 | 44 | - Transition to Rust 2021 edition (thanks to Tim Bates) 45 | - Expose more accurate Ledger model (keep the order of items and expose 'include' directives) 46 | - Move the simplified model and conversion code to the new 'ledger-utils' crate 47 | - Make SerializerSettings and LedgerItem non-exhaustive 48 | - Fix numerical comment being parsed as an amount (thanks to Tim Bates) 49 | - Ensure date & datetime is always serialized properly 50 | 51 | ## [3.1.0] - 2020-04-30 52 | 53 | ### Added 54 | 55 | - Serializer with configurable indent 56 | 57 | ### Changed 58 | 59 | - Transition to Rust 2018 edition 60 | 61 | ## [3.0.0] - 2019-07-08 62 | 63 | ### Added 64 | 65 | - Support for optional `amounts` (thanks to Emmanuel Surleau) 66 | - Support for `balance` assertion field in posting (thanks to John Beisle) 67 | 68 | ### Changed 69 | 70 | - Update rust_decimal crate 71 | - Support for `,` as thousands separator (thanks to Emmanuel Surleau) 72 | - Support for commodities starting with a non-ASCII character (thanks to Emmanuel Surleau) 73 | - Fix model::Posting not displaying its status (thanks to John Beisle) 74 | 75 | ## [2.2.0] - 2019-02-17 76 | 77 | ### Added 78 | 79 | - Display traits (thanks to Zoran Zaric) 80 | - Parsing of line comments attached to (following) transactions and postings 81 | 82 | ### Changed 83 | 84 | - Fix tests by ignoring code blocks in docs (thanks to Zoran Zaric) 85 | - Upgrade rust_decimal crate 86 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ledger-parser" 3 | version = "7.0.0" 4 | authors = ["Marek Gibek "] 5 | description = "Rust library for parsing ledger cli (https://www.ledger-cli.org/) input files." 6 | license = "Unlicense" 7 | repository = "https://github.com/marek-g/rust-ledger-parser" 8 | readme = "README.md" 9 | documentation = "https://docs.rs/ledger-parser" 10 | keywords = ["parser", "ledger", "ledger-cli"] 11 | categories = ["parser-implementations"] 12 | edition = "2021" 13 | 14 | [dependencies] 15 | nom = "7" 16 | chrono = "0.4" 17 | rust_decimal = "1" 18 | ordered-float = "4" 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ledger-parser 2 | 3 | [![Crates.io Version](https://img.shields.io/crates/v/ledger-parser.svg)](https://crates.io/crates/ledger-parser) 4 | [![Docs.rs Version](https://docs.rs/ledger-parser/badge.svg)](https://docs.rs/ledger-parser) 5 | [![License Unlicense](https://img.shields.io/crates/l/ledger-parser.svg)](http://unlicense.org/UNLICENSE) 6 | 7 | Rust library for parsing [Ledger-cli](https://www.ledger-cli.org/) input files. 8 | 9 | ## File format 10 | 11 | Only a subset of the ledger-cli's file format is implemented. 12 | 13 | Supported elements: 14 | 15 | - Line comments (starting with: `; # % | *`) 16 | 17 | - Inline comments (starting with `;`) 18 | 19 | - Transaction headers with format (minimum two spaces or one tab between `DESC` and `NOTE`): 20 | 21 | ```ledger-cli 22 | DATE[=EDATE] [*|!] [(CODE)] DESC [; NOTE] 23 | ``` 24 | 25 | - Transaction postings with format (minimum two spaces or one tab between `ACCOUNT` and `AMOUNT`): 26 | 27 | ```ledger-cli 28 | ACCOUNT [AMOUNT] [= BALANCE] [; NOTE] 29 | ``` 30 | 31 | - Virtual accounts are supported 32 | 33 | - Posting comments with dates, effective dates, and metadata tags are supported 34 | ```ledger-cli 35 | ACCOUNT [AMOUNT] [= BALANCE] ; [YYYY-MM-DD] 36 | ACCOUNT [AMOUNT] [= BALANCE] ; [=YYYY-MM-DD] 37 | ACCOUNT [AMOUNT] [= BALANCE] ; :TAG1:TAG2: 38 | ACCOUNT [AMOUNT] [= BALANCE] ; Tag: String Value 39 | ACCOUNT [AMOUNT] [= BALANCE] ; Tag:: -1 40 | ACCOUNT [AMOUNT] [= BALANCE] ; Tag:: 1.0 41 | ACCOUNT [AMOUNT] [= BALANCE] ; Tag:: [YYYY-MM-DD] 42 | ``` 43 | 44 | - `AMOUNT` can be combined with lot and commodity prices ({}, {{}}, @, @@) 45 | 46 | - Commodity prices with format: 47 | 48 | ```ledger-cli 49 | P DATE SYMBOL PRICE 50 | ``` 51 | 52 | - Command directives: `include` 53 | 54 | ## Example 55 | 56 | Parsing: 57 | 58 | ```rust 59 | let ledger: ledger_parser::Ledger = r#"; Example 1 60 | 2018-10-01=2018-10-14 ! (123) Description 61 | ; Transaction comment 62 | TEST:Account 123 $1.20 63 | ; Posting comment 64 | TEST:Account 345 -$1.20"# 65 | .parse()?; 66 | ``` 67 | 68 | Serializing: 69 | 70 | ```rust 71 | use ledger_parser::{ Serializer, SerializerSettings }; 72 | 73 | println!("{}", ledger); 74 | println!("{}", ledger.to_string_pretty(&SerializerSettings::default().with_indent("\t"))); 75 | ``` 76 | 77 | ## See also 78 | 79 | - [ledger-utils](https://crates.io/crates/ledger-utils) - ledger-cli file processing Rust library, useful for calculating balances, creating reports etc. 80 | -------------------------------------------------------------------------------- /UNLICENSE.txt: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Rust library for parsing [Ledger-cli](https://www.ledger-cli.org/) input files. 2 | //! 3 | //! Only a subset of the ledger-cli's file format is implemented. 4 | //! 5 | //! Supported elements: 6 | //! 7 | //! - Line comments (starting with: ``; # % | *``) 8 | //! 9 | //! - Inline comments (starting with ``;``) 10 | //! 11 | //! - Transaction headers with format (minimum two spaces or one tab between `DESC` and `NOTE`): 12 | //! 13 | //! ```ledger-cli,ignore 14 | //! DATE[=EDATE] [*|!] [(CODE)] DESC [; NOTE] 15 | //! ``` 16 | //! 17 | //! - Transaction postings with format (minimum two spaces or one tab between `ACCOUNT` and `AMOUNT`): 18 | //! 19 | //! ```ledger-cli,ignore 20 | //! ACCOUNT [AMOUNT] [= BALANCE] [; NOTE] 21 | //! ``` 22 | //! 23 | //! - Virtual accounts are supported 24 | //! 25 | //! - `AMOUNT` can be combined with lot and commodity prices ({}, {{}}, @, @@) 26 | //! 27 | //! - Commodity prices with format: 28 | //! 29 | //! ```ledger-cli,ignore 30 | //! P DATE SYMBOL PRICE 31 | //! ``` 32 | //! - Command directives: `include` 33 | 34 | mod model; 35 | pub use model::*; 36 | 37 | mod serializer; 38 | pub use serializer::*; 39 | 40 | mod parser; 41 | 42 | use std::fmt; 43 | 44 | #[derive(Clone, Debug, PartialEq, Eq)] 45 | pub enum ParseError { 46 | String(String), 47 | } 48 | 49 | impl fmt::Display for ParseError { 50 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 51 | match *self { 52 | ParseError::String(ref err) => err.fmt(f), 53 | } 54 | } 55 | } 56 | 57 | impl std::error::Error for ParseError { 58 | fn description(&self) -> &str { 59 | match *self { 60 | ParseError::String(ref err) => err, 61 | } 62 | } 63 | } 64 | 65 | /// Parses ledger-cli source to AST tree. 66 | /// 67 | /// # Examples 68 | /// 69 | /// ``` 70 | /// let result = ledger_parser::parse(r#"; Example 1 71 | /// 2018-10-01=2018-10-14 ! (123) Description 72 | /// ; Transaction comment 73 | /// TEST:Account 123 $1.20 74 | /// ; Posting comment 75 | /// TEST:Account 345 -$1.20"#); 76 | /// ``` 77 | pub fn parse(input: &str) -> Result { 78 | input.parse() 79 | } 80 | -------------------------------------------------------------------------------- /src/model.rs: -------------------------------------------------------------------------------- 1 | use crate::parser; 2 | use crate::serializer::*; 3 | use crate::ParseError; 4 | use chrono::{NaiveDate, NaiveDateTime}; 5 | use nom::{error::convert_error, Finish}; 6 | use ordered_float::NotNan; 7 | use rust_decimal::Decimal; 8 | use std::fmt; 9 | use std::str::FromStr; 10 | 11 | /// 12 | /// Main document. Contains transactions and/or commodity prices. 13 | /// 14 | #[derive(Debug, PartialEq, Eq, Clone)] 15 | pub struct Ledger { 16 | pub items: Vec, 17 | } 18 | 19 | impl FromStr for Ledger { 20 | type Err = ParseError; 21 | 22 | fn from_str(input: &str) -> Result { 23 | let result = parser::parse_ledger(input); 24 | match result.finish() { 25 | Ok((_, result)) => Ok(result), 26 | Err(error) => Err(ParseError::String(convert_error(input, error))), 27 | } 28 | } 29 | } 30 | 31 | impl fmt::Display for Ledger { 32 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 33 | write!( 34 | f, 35 | "{}", 36 | self.to_string_pretty(&SerializerSettings::default()) 37 | )?; 38 | Ok(()) 39 | } 40 | } 41 | 42 | #[non_exhaustive] 43 | #[derive(Debug, PartialEq, Eq, Clone)] 44 | pub enum LedgerItem { 45 | EmptyLine, 46 | LineComment(String), 47 | Transaction(Transaction), 48 | CommodityPrice(CommodityPrice), 49 | Include(String), 50 | } 51 | 52 | impl fmt::Display for LedgerItem { 53 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 54 | write!( 55 | f, 56 | "{}", 57 | self.to_string_pretty(&SerializerSettings::default()) 58 | )?; 59 | Ok(()) 60 | } 61 | } 62 | 63 | /// 64 | /// Transaction. 65 | /// 66 | #[derive(Debug, PartialEq, Eq, Clone)] 67 | pub struct Transaction { 68 | pub status: Option, 69 | pub code: Option, 70 | pub description: Option, 71 | pub comment: Option, 72 | pub date: NaiveDate, 73 | pub effective_date: Option, 74 | pub posting_metadata: PostingMetadata, 75 | pub postings: Vec, 76 | } 77 | 78 | impl fmt::Display for Transaction { 79 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 80 | write!( 81 | f, 82 | "{}", 83 | self.to_string_pretty(&SerializerSettings::default()) 84 | )?; 85 | Ok(()) 86 | } 87 | } 88 | 89 | #[derive(Debug, PartialEq, Eq, Clone, Copy)] 90 | pub enum TransactionStatus { 91 | Pending, 92 | Cleared, 93 | } 94 | 95 | impl fmt::Display for TransactionStatus { 96 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 97 | write!( 98 | f, 99 | "{}", 100 | self.to_string_pretty(&SerializerSettings::default()) 101 | )?; 102 | Ok(()) 103 | } 104 | } 105 | 106 | #[derive(Debug, PartialEq, Eq, Clone)] 107 | pub struct Posting { 108 | pub account: String, 109 | pub reality: Reality, 110 | pub amount: Option, 111 | pub balance: Option, 112 | pub status: Option, 113 | pub comment: Option, 114 | pub metadata: PostingMetadata, 115 | } 116 | 117 | impl fmt::Display for Posting { 118 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 119 | write!( 120 | f, 121 | "{}", 122 | self.to_string_pretty(&SerializerSettings::default()) 123 | )?; 124 | Ok(()) 125 | } 126 | } 127 | 128 | #[derive(Debug, PartialEq, Eq, Clone, Copy)] 129 | pub enum Reality { 130 | Real, 131 | BalancedVirtual, 132 | UnbalancedVirtual, 133 | } 134 | 135 | #[derive(Debug, PartialEq, Eq, Clone)] 136 | pub struct PostingAmount { 137 | pub amount: Amount, 138 | pub lot_price: Option, 139 | pub price: Option, 140 | } 141 | 142 | impl fmt::Display for PostingAmount { 143 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 144 | write!( 145 | f, 146 | "{}", 147 | self.to_string_pretty(&SerializerSettings::default()) 148 | )?; 149 | Ok(()) 150 | } 151 | } 152 | 153 | #[derive(Debug, PartialEq, Eq, Clone)] 154 | pub struct Amount { 155 | pub quantity: Decimal, 156 | pub commodity: Commodity, 157 | } 158 | 159 | impl fmt::Display for Amount { 160 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 161 | write!( 162 | f, 163 | "{}", 164 | self.to_string_pretty(&SerializerSettings::default()) 165 | )?; 166 | Ok(()) 167 | } 168 | } 169 | 170 | #[derive(Debug, PartialEq, Eq, Clone)] 171 | pub struct Commodity { 172 | pub name: String, 173 | pub position: CommodityPosition, 174 | } 175 | 176 | #[derive(Debug, PartialEq, Eq, Clone, Copy)] 177 | pub enum CommodityPosition { 178 | Left, 179 | Right, 180 | } 181 | 182 | #[derive(Debug, PartialEq, Eq, Clone)] 183 | pub enum Price { 184 | Unit(Amount), 185 | Total(Amount), 186 | } 187 | 188 | #[derive(Debug, PartialEq, Eq, Clone)] 189 | pub enum Balance { 190 | Zero, 191 | Amount(Amount), 192 | } 193 | 194 | impl fmt::Display for Balance { 195 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 196 | write!( 197 | f, 198 | "{}", 199 | self.to_string_pretty(&SerializerSettings::default()) 200 | )?; 201 | Ok(()) 202 | } 203 | } 204 | 205 | /// 206 | /// Commodity price. 207 | /// 208 | #[derive(Debug, PartialEq, Eq, Clone)] 209 | pub struct CommodityPrice { 210 | pub datetime: NaiveDateTime, 211 | pub commodity_name: String, 212 | pub amount: Amount, 213 | } 214 | 215 | impl fmt::Display for CommodityPrice { 216 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 217 | write!( 218 | f, 219 | "{}", 220 | self.to_string_pretty(&SerializerSettings::default()) 221 | )?; 222 | Ok(()) 223 | } 224 | } 225 | 226 | /// 227 | /// Posting metadata. Also appears on Transaction 228 | /// 229 | #[derive(Debug, PartialEq, Eq, Clone)] 230 | pub struct PostingMetadata { 231 | pub date: Option, 232 | pub effective_date: Option, 233 | pub tags: Vec, 234 | } 235 | 236 | #[derive(Clone, Debug, PartialEq, Eq)] 237 | pub struct Tag { 238 | pub name: String, 239 | pub value: Option, 240 | } 241 | 242 | #[derive(Clone, Debug, PartialEq, Eq)] 243 | pub enum TagValue { 244 | String(String), 245 | Integer(i64), 246 | Float(NotNan), 247 | Date(NaiveDate), 248 | } 249 | 250 | impl fmt::Display for TagValue { 251 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 252 | match self { 253 | TagValue::String(v) => v.fmt(f), 254 | TagValue::Integer(v) => v.fmt(f), 255 | TagValue::Float(v) => v.fmt(f), 256 | TagValue::Date(v) => write!(f, "[{v}]"), 257 | } 258 | } 259 | } 260 | 261 | #[cfg(test)] 262 | mod tests { 263 | use super::*; 264 | use chrono::NaiveDate; 265 | use rust_decimal::Decimal; 266 | 267 | #[test] 268 | fn display_transaction_status() { 269 | assert_eq!(format!("{}", TransactionStatus::Pending), "!"); 270 | assert_eq!(format!("{}", TransactionStatus::Cleared), "*"); 271 | } 272 | 273 | #[test] 274 | fn display_amount() { 275 | assert_eq!( 276 | format!( 277 | "{}", 278 | Amount { 279 | quantity: Decimal::new(4200, 2), 280 | commodity: Commodity { 281 | name: "€".to_owned(), 282 | position: CommodityPosition::Right, 283 | } 284 | } 285 | ), 286 | "42.00 €" 287 | ); 288 | assert_eq!( 289 | format!( 290 | "{}", 291 | Amount { 292 | quantity: Decimal::new(4200, 2), 293 | commodity: Commodity { 294 | name: "USD".to_owned(), 295 | position: CommodityPosition::Left, 296 | } 297 | } 298 | ), 299 | "USD42.00" 300 | ); 301 | } 302 | 303 | #[test] 304 | fn display_commodity_price() { 305 | let actual = format!( 306 | "{}", 307 | CommodityPrice { 308 | datetime: NaiveDate::from_ymd_opt(2017, 11, 12) 309 | .unwrap() 310 | .and_hms_opt(12, 0, 0) 311 | .unwrap(), 312 | commodity_name: "mBH".to_owned(), 313 | amount: Amount { 314 | quantity: Decimal::new(500, 2), 315 | commodity: Commodity { 316 | name: "PLN".to_owned(), 317 | position: CommodityPosition::Right 318 | } 319 | } 320 | } 321 | ); 322 | let expected = "P 2017-11-12 12:00:00 mBH 5.00 PLN"; 323 | assert_eq!(actual, expected); 324 | } 325 | 326 | #[test] 327 | fn display_balance() { 328 | assert_eq!( 329 | format!( 330 | "{}", 331 | Balance::Amount(Amount { 332 | quantity: Decimal::new(4200, 2), 333 | commodity: Commodity { 334 | name: "€".to_owned(), 335 | position: CommodityPosition::Right, 336 | } 337 | }) 338 | ), 339 | "42.00 €" 340 | ); 341 | assert_eq!(format!("{}", Balance::Zero), "0"); 342 | } 343 | 344 | #[test] 345 | fn display_posting() { 346 | assert_eq!( 347 | format!( 348 | "{}", 349 | Posting { 350 | account: "Assets:Checking".to_owned(), 351 | reality: Reality::Real, 352 | amount: Some(PostingAmount { 353 | amount: Amount { 354 | quantity: Decimal::new(4200, 2), 355 | commodity: Commodity { 356 | name: "USD".to_owned(), 357 | position: CommodityPosition::Left, 358 | } 359 | }, 360 | lot_price: None, 361 | price: None, 362 | }), 363 | balance: Some(Balance::Amount(Amount { 364 | quantity: Decimal::new(5000, 2), 365 | commodity: Commodity { 366 | name: "USD".to_owned(), 367 | position: CommodityPosition::Left, 368 | } 369 | })), 370 | status: Some(TransactionStatus::Cleared), 371 | comment: Some("asdf".to_owned()), 372 | metadata: PostingMetadata { 373 | date: None, 374 | effective_date: None, 375 | tags: vec![], 376 | }, 377 | } 378 | ), 379 | "* Assets:Checking USD42.00 = USD50.00\n ; asdf" 380 | ); 381 | } 382 | 383 | #[test] 384 | fn display_transaction() { 385 | let actual = format!( 386 | "{}", 387 | Transaction { 388 | comment: Some("Comment Line 1\nComment Line 2".to_owned()), 389 | date: NaiveDate::from_ymd_opt(2018, 10, 1).unwrap(), 390 | effective_date: Some(NaiveDate::from_ymd_opt(2018, 10, 14).unwrap()), 391 | status: Some(TransactionStatus::Pending), 392 | code: Some("123".to_owned()), 393 | description: Some("Marek Ogarek".to_owned()), 394 | posting_metadata: PostingMetadata { 395 | date: None, 396 | effective_date: None, 397 | tags: vec![], 398 | }, 399 | postings: vec![ 400 | Posting { 401 | account: "TEST:ABC 123".to_owned(), 402 | reality: Reality::Real, 403 | amount: Some(PostingAmount { 404 | amount: Amount { 405 | quantity: Decimal::new(120, 2), 406 | commodity: Commodity { 407 | name: "$".to_owned(), 408 | position: CommodityPosition::Left 409 | } 410 | }, 411 | lot_price: None, 412 | price: None 413 | }), 414 | balance: None, 415 | status: None, 416 | comment: Some("dd".to_owned()), 417 | metadata: PostingMetadata { 418 | date: None, 419 | effective_date: None, 420 | tags: vec![], 421 | }, 422 | }, 423 | Posting { 424 | account: "TEST:ABC 123".to_owned(), 425 | reality: Reality::Real, 426 | amount: Some(PostingAmount { 427 | amount: Amount { 428 | quantity: Decimal::new(120, 2), 429 | commodity: Commodity { 430 | name: "$".to_owned(), 431 | position: CommodityPosition::Left 432 | } 433 | }, 434 | lot_price: None, 435 | price: None 436 | }), 437 | balance: None, 438 | status: None, 439 | comment: None, 440 | metadata: PostingMetadata { 441 | date: None, 442 | effective_date: None, 443 | tags: vec![], 444 | }, 445 | } 446 | ] 447 | }, 448 | ); 449 | let expected = r#"2018-10-01=2018-10-14 ! (123) Marek Ogarek 450 | ; Comment Line 1 451 | ; Comment Line 2 452 | TEST:ABC 123 $1.20 453 | ; dd 454 | TEST:ABC 123 $1.20"#; 455 | assert_eq!(actual, expected); 456 | } 457 | 458 | #[test] 459 | fn display_ledger() { 460 | let actual = format!( 461 | "{}", 462 | Ledger { 463 | items: vec![ 464 | LedgerItem::Transaction(Transaction { 465 | comment: Some("Comment Line 1\nComment Line 2".to_owned()), 466 | date: NaiveDate::from_ymd_opt(2018, 10, 1).unwrap(), 467 | effective_date: Some(NaiveDate::from_ymd_opt(2018, 10, 14).unwrap()), 468 | status: Some(TransactionStatus::Pending), 469 | code: Some("123".to_owned()), 470 | description: Some("Marek Ogarek".to_owned()), 471 | posting_metadata: PostingMetadata { 472 | date: None, 473 | effective_date: None, 474 | tags: vec![], 475 | }, 476 | postings: vec![ 477 | Posting { 478 | account: "TEST:ABC 123".to_owned(), 479 | reality: Reality::Real, 480 | amount: Some(PostingAmount { 481 | amount: Amount { 482 | quantity: Decimal::new(120, 2), 483 | commodity: Commodity { 484 | name: "$".to_owned(), 485 | position: CommodityPosition::Left 486 | } 487 | }, 488 | lot_price: None, 489 | price: None 490 | }), 491 | balance: None, 492 | status: None, 493 | comment: Some("dd".to_owned()), 494 | metadata: PostingMetadata { 495 | date: None, 496 | effective_date: None, 497 | tags: vec![], 498 | }, 499 | }, 500 | Posting { 501 | account: "TEST:ABC 123".to_owned(), 502 | reality: Reality::Real, 503 | amount: Some(PostingAmount { 504 | amount: Amount { 505 | quantity: Decimal::new(120, 2), 506 | commodity: Commodity { 507 | name: "$".to_owned(), 508 | position: CommodityPosition::Left 509 | } 510 | }, 511 | lot_price: None, 512 | price: None 513 | }), 514 | balance: None, 515 | status: None, 516 | comment: None, 517 | metadata: PostingMetadata { 518 | date: None, 519 | effective_date: None, 520 | tags: vec![], 521 | }, 522 | } 523 | ] 524 | }), 525 | LedgerItem::EmptyLine, 526 | LedgerItem::Transaction(Transaction { 527 | comment: None, 528 | date: NaiveDate::from_ymd_opt(2018, 10, 1).unwrap(), 529 | effective_date: Some(NaiveDate::from_ymd_opt(2018, 10, 14).unwrap()), 530 | posting_metadata: PostingMetadata { 531 | date: None, 532 | effective_date: None, 533 | tags: vec![], 534 | }, 535 | status: Some(TransactionStatus::Pending), 536 | code: Some("123".to_owned()), 537 | description: Some("Marek Ogarek".to_owned()), 538 | postings: vec![ 539 | Posting { 540 | account: "TEST:ABC 123".to_owned(), 541 | reality: Reality::Real, 542 | amount: Some(PostingAmount { 543 | amount: Amount { 544 | quantity: Decimal::new(120, 2), 545 | commodity: Commodity { 546 | name: "$".to_owned(), 547 | position: CommodityPosition::Left 548 | } 549 | }, 550 | lot_price: Some(Price::Unit(Amount { 551 | quantity: Decimal::new(500, 2), 552 | commodity: Commodity { 553 | name: "PLN".to_owned(), 554 | position: CommodityPosition::Right 555 | } 556 | })), 557 | price: Some(Price::Unit(Amount { 558 | quantity: Decimal::new(600, 2), 559 | commodity: Commodity { 560 | name: "PLN".to_owned(), 561 | position: CommodityPosition::Right 562 | } 563 | })) 564 | }), 565 | balance: None, 566 | status: None, 567 | comment: None, 568 | metadata: PostingMetadata { 569 | date: None, 570 | effective_date: None, 571 | tags: vec![], 572 | }, 573 | }, 574 | Posting { 575 | account: "TEST:ABC 123".to_owned(), 576 | reality: Reality::Real, 577 | amount: Some(PostingAmount { 578 | amount: Amount { 579 | quantity: Decimal::new(120, 2), 580 | commodity: Commodity { 581 | name: "$".to_owned(), 582 | position: CommodityPosition::Left 583 | } 584 | }, 585 | lot_price: Some(Price::Total(Amount { 586 | quantity: Decimal::new(500, 2), 587 | commodity: Commodity { 588 | name: "PLN".to_owned(), 589 | position: CommodityPosition::Right 590 | } 591 | })), 592 | price: Some(Price::Total(Amount { 593 | quantity: Decimal::new(600, 2), 594 | commodity: Commodity { 595 | name: "PLN".to_owned(), 596 | position: CommodityPosition::Right 597 | } 598 | })) 599 | }), 600 | balance: None, 601 | status: None, 602 | comment: None, 603 | metadata: PostingMetadata { 604 | date: None, 605 | effective_date: None, 606 | tags: vec![], 607 | }, 608 | } 609 | ] 610 | }), 611 | LedgerItem::EmptyLine, 612 | LedgerItem::CommodityPrice(CommodityPrice { 613 | datetime: NaiveDate::from_ymd_opt(2017, 11, 12) 614 | .unwrap() 615 | .and_hms_opt(12, 0, 0) 616 | .unwrap(), 617 | commodity_name: "mBH".to_owned(), 618 | amount: Amount { 619 | quantity: Decimal::new(500, 2), 620 | commodity: Commodity { 621 | name: "PLN".to_owned(), 622 | position: CommodityPosition::Right 623 | } 624 | } 625 | }), 626 | ] 627 | } 628 | ); 629 | let expected = r#"2018-10-01=2018-10-14 ! (123) Marek Ogarek 630 | ; Comment Line 1 631 | ; Comment Line 2 632 | TEST:ABC 123 $1.20 633 | ; dd 634 | TEST:ABC 123 $1.20 635 | 636 | 2018-10-01=2018-10-14 ! (123) Marek Ogarek 637 | TEST:ABC 123 $1.20 {5.00 PLN} @ 6.00 PLN 638 | TEST:ABC 123 $1.20 {{5.00 PLN}} @@ 6.00 PLN 639 | 640 | P 2017-11-12 12:00:00 mBH 5.00 PLN 641 | "#; 642 | assert_eq!(actual, expected); 643 | } 644 | } 645 | -------------------------------------------------------------------------------- /src/parser.rs: -------------------------------------------------------------------------------- 1 | use chrono::{NaiveDate, NaiveDateTime}; 2 | use nom::{ 3 | branch::alt, 4 | bytes::complete::{is_not, tag, take_while1, take_while_m_n}, 5 | character::complete::{ 6 | alphanumeric1, char, digit0, digit1, line_ending, none_of, not_line_ending, space0, space1, 7 | }, 8 | combinator::{eof, map, map_opt, map_res, not, opt, peek, recognize, value, verify}, 9 | error::VerboseError, 10 | multi::{fold_many0, fold_many1, many0, many1, separated_list1}, 11 | number::complete::double, 12 | sequence::{delimited, pair, preceded, separated_pair, terminated, tuple}, 13 | AsChar, Err, IResult, Needed, Parser, 14 | }; 15 | use ordered_float::NotNan; 16 | use rust_decimal::Decimal; 17 | use std::str::FromStr; 18 | 19 | use crate::model::*; 20 | 21 | type LedgerParseResult<'a, T> = IResult<&'a str, T, VerboseError<&'a str>>; 22 | 23 | fn is_commodity_char(c: char) -> bool { 24 | !"0123456789{}[]()~`!@#%^&*-=+\\'\",./? ;\t\r\n".contains(c) 25 | } 26 | 27 | fn eol_or_eof(input: &str) -> LedgerParseResult<&str> { 28 | alt((line_ending, eof))(input) 29 | } 30 | 31 | fn number_n<'a>(n: usize) -> impl FnMut(&'a str) -> IResult<&'a str, i32, VerboseError<&str>> { 32 | map_res(take_while_m_n(n, n, AsChar::is_dec_digit), i32::from_str) 33 | } 34 | 35 | fn parse_date_internal(input: &str) -> LedgerParseResult<(i32, i32, i32)> { 36 | tuple(( 37 | terminated(number_n(4), alt((char('-'), char('/'), char('.')))), 38 | terminated(number_n(2), alt((char('-'), char('/'), char('.')))), 39 | number_n(2), 40 | ))(input) 41 | } 42 | 43 | fn parse_time_internal(input: &str) -> LedgerParseResult<(i32, i32, i32)> { 44 | tuple(( 45 | terminated(number_n(2), char(':')), 46 | terminated(number_n(2), char(':')), 47 | number_n(2), 48 | ))(input) 49 | } 50 | 51 | fn parse_datetime_internal(input: &str) -> LedgerParseResult<(i32, i32, i32, i32, i32, i32)> { 52 | separated_pair(parse_date_internal, space1, parse_time_internal) 53 | .map(|(date, time)| (date.0, date.1, date.2, time.0, time.1, time.2)) 54 | .parse(input) 55 | } 56 | 57 | fn parse_date(input: &str) -> LedgerParseResult { 58 | map_opt(parse_date_internal, |value| { 59 | NaiveDate::from_ymd_opt(value.0, value.1 as u32, value.2 as u32) 60 | })(input) 61 | } 62 | 63 | fn parse_datetime(input: &str) -> LedgerParseResult { 64 | map_opt( 65 | parse_datetime_internal, 66 | |value| match NaiveDate::from_ymd_opt(value.0, value.1 as u32, value.2 as u32) { 67 | Some(date) => date.and_hms_opt(value.3 as u32, value.4 as u32, value.5 as u32), 68 | None => None, 69 | }, 70 | )(input) 71 | } 72 | 73 | fn parse_quantity(input: &str) -> LedgerParseResult { 74 | map_res( 75 | tuple(( 76 | opt(tag("-")), 77 | alt(( 78 | pair( 79 | take_while_m_n(1, 3, AsChar::is_dec_digit), 80 | many1(preceded( 81 | char(','), 82 | take_while_m_n(3, 3, AsChar::is_dec_digit).map(str::to_owned), 83 | )), 84 | ) 85 | .map(|(leading, rest)| format!("{}{}", leading, rest.join(""))), 86 | digit0.map(str::to_owned), 87 | )), 88 | opt(recognize(preceded(char('.'), digit1))), 89 | )) 90 | .map(|(sign, decimal, fractional)| { 91 | format!( 92 | "{}{}{}", 93 | sign.unwrap_or(""), 94 | decimal, 95 | fractional.unwrap_or("") 96 | ) 97 | }), 98 | |s: String| Decimal::from_str(&s), 99 | )(input) 100 | } 101 | 102 | fn string_fragment(input: &str) -> LedgerParseResult<&str> { 103 | alt(( 104 | verify(is_not("\\\""), |s: &str| !s.is_empty()), 105 | value("\"", tag("\\\"")), 106 | ))(input) 107 | } 108 | 109 | fn string_between_quotes(input: &str) -> LedgerParseResult { 110 | let string_contents = fold_many1(string_fragment, String::new, |mut string, fragment| { 111 | string.push_str(fragment); 112 | string 113 | }); 114 | 115 | delimited(char('"'), string_contents, char('"'))(input) 116 | } 117 | 118 | fn commodity_without_quotes(input: &str) -> LedgerParseResult { 119 | take_while1(is_commodity_char) 120 | .map(str::to_owned) 121 | .parse(input) 122 | } 123 | 124 | fn parse_commodity(input: &str) -> LedgerParseResult { 125 | alt((string_between_quotes, commodity_without_quotes))(input) 126 | } 127 | 128 | fn parse_amount(input: &str) -> LedgerParseResult { 129 | alt(( 130 | tuple(( 131 | opt(terminated(char('-'), space0)), 132 | terminated(parse_commodity, space0), 133 | parse_quantity, 134 | )) 135 | .map(|(neg_opt, name, quantity)| Amount { 136 | quantity: if neg_opt.is_some() { 137 | quantity * Decimal::new(-1, 0) 138 | } else { 139 | quantity 140 | }, 141 | commodity: Commodity { 142 | name, 143 | position: CommodityPosition::Left, 144 | }, 145 | }), 146 | pair(terminated(parse_quantity, space0), parse_commodity).map(|(quantity, name)| Amount { 147 | quantity, 148 | commodity: Commodity { 149 | name, 150 | position: CommodityPosition::Right, 151 | }, 152 | }), 153 | ))(input) 154 | } 155 | 156 | fn parse_posting_amount(input: &str) -> LedgerParseResult { 157 | let (input, amount) = parse_amount(input)?; 158 | let (input, lot_price) = opt(preceded(space0, parse_lot_price))(input)?; 159 | let (input, price) = opt(preceded(space0, parse_price))(input)?; 160 | Ok(( 161 | input, 162 | PostingAmount { 163 | amount, 164 | lot_price, 165 | price, 166 | }, 167 | )) 168 | } 169 | 170 | fn parse_lot_price(input: &str) -> LedgerParseResult { 171 | alt(( 172 | delimited( 173 | pair(tag("{{"), space0), 174 | parse_amount, 175 | pair(space0, tag("}}")), 176 | ) 177 | .map(Price::Total), 178 | delimited( 179 | pair(char('{'), space0), 180 | parse_amount, 181 | pair(space0, char('}')), 182 | ) 183 | .map(Price::Unit), 184 | ))(input) 185 | } 186 | 187 | fn parse_price(input: &str) -> LedgerParseResult { 188 | alt(( 189 | preceded(pair(tag("@@"), space0), parse_amount).map(Price::Total), 190 | preceded(pair(char('@'), space0), parse_amount).map(Price::Unit), 191 | ))(input) 192 | } 193 | 194 | fn parse_balance(input: &str) -> LedgerParseResult { 195 | alt(( 196 | parse_amount.map(Balance::Amount), 197 | value(Balance::Zero, char('0')), 198 | ))(input) 199 | } 200 | 201 | fn parse_commodity_price(input: &str) -> LedgerParseResult { 202 | let (input, _) = char('P')(input)?; 203 | let (input, datetime) = preceded(space1, parse_datetime)(input)?; 204 | let (input, commodity_name) = preceded(space1, parse_commodity)(input)?; 205 | let (input, amount) = preceded(space1, parse_amount)(input)?; 206 | let (input, _) = preceded(space0, opt(preceded(char(';'), not_line_ending)))(input)?; 207 | let (input, _) = eol_or_eof(input)?; 208 | 209 | Ok(( 210 | input, 211 | CommodityPrice { 212 | datetime, 213 | commodity_name, 214 | amount, 215 | }, 216 | )) 217 | } 218 | 219 | fn parse_empty_line(input: &str) -> LedgerParseResult<&str> { 220 | alt(( 221 | terminated(space0, line_ending), 222 | terminated(space1, eof), // Must consume something or many0 errors to prevent infinite loop 223 | ))(input) 224 | } 225 | 226 | fn parse_global_line_comment(input: &str) -> LedgerParseResult<&str> { 227 | let (input, _) = delimited( 228 | space0, 229 | alt((char(';'), char('#'), char('%'), char('|'), char('*'))), 230 | space0, 231 | )(input)?; 232 | terminated(not_line_ending.map(str::trim_end), eol_or_eof)(input) 233 | } 234 | 235 | #[derive(Default)] 236 | struct Metadata { 237 | comment: Option, 238 | date: Option, 239 | effective_date: Option, 240 | tags: Vec, 241 | } 242 | 243 | fn parse_metadata_date(input: &str) -> LedgerParseResult { 244 | map( 245 | preceded( 246 | space0, 247 | delimited( 248 | char('['), 249 | pair(opt(parse_date), opt(preceded(char('='), parse_date))), 250 | char(']'), 251 | ), 252 | ), 253 | |(date, effective_date)| Metadata { 254 | date, 255 | effective_date, 256 | ..Default::default() 257 | }, 258 | )(input) 259 | } 260 | 261 | fn parse_tag_value(input: &str) -> LedgerParseResult { 262 | alt(( 263 | map_res( 264 | recognize(pair(opt(char('-')), terminated(digit1, not(char('.'))))), 265 | str::parse, 266 | ) 267 | .map(TagValue::Integer), 268 | map_res(double, NotNan::new).map(TagValue::Float), 269 | delimited(char('['), parse_date, char(']')).map(TagValue::Date), 270 | ))(input) 271 | } 272 | 273 | fn parse_metadata_tag_with_value(input: &str) -> LedgerParseResult { 274 | map( 275 | preceded( 276 | space0, 277 | alt(( 278 | pair( 279 | terminated(alphanumeric1, pair(char(':'), space1)), 280 | not_line_ending.map(str::trim_end), 281 | ) 282 | .map(|(name, value)| (name, TagValue::String(value.to_owned()))), 283 | pair( 284 | terminated(alphanumeric1, pair(tag("::"), space1)), 285 | parse_tag_value, 286 | ), 287 | )), 288 | ), 289 | |(name, value)| Metadata { 290 | tags: vec![Tag { 291 | name: name.to_owned(), 292 | value: Some(value), 293 | }], 294 | ..Default::default() 295 | }, 296 | )(input) 297 | } 298 | 299 | fn parse_tags(input: &str) -> LedgerParseResult> { 300 | delimited( 301 | char(':'), 302 | separated_list1( 303 | char(':'), 304 | alphanumeric1 305 | .map(str::to_owned) 306 | .map(|name| Tag { name, value: None }), 307 | ), 308 | char(':'), 309 | )(input) 310 | } 311 | 312 | fn parse_comment_with_tags(input: &str) -> LedgerParseResult { 313 | map( 314 | preceded( 315 | space0, 316 | tuple(( 317 | opt(recognize(many1( 318 | none_of(":\r\n").or(not(parse_tags).and(char(':')).map(|(_, c)| c)), 319 | )) 320 | .map(str::trim_end)), 321 | opt(parse_tags), 322 | opt(verify(not_line_ending.map(str::trim), |s: &str| { 323 | !s.is_empty() 324 | })), 325 | )), 326 | ), 327 | |(s1, v, s2)| Metadata { 328 | comment: s1 329 | .into_iter() 330 | .chain(s2) 331 | .map(str::to_owned) 332 | .reduce(|mut a, b| { 333 | a.push(' '); 334 | a.push_str(&b); 335 | a 336 | }), 337 | tags: v.unwrap_or_default(), 338 | ..Default::default() 339 | }, 340 | )(input) 341 | } 342 | 343 | fn parse_metadata_comments(input: &str) -> LedgerParseResult { 344 | terminated( 345 | fold_many0( 346 | preceded( 347 | many0(pair(space0, line_ending)), 348 | preceded( 349 | pair(space0, char(';')), 350 | alt(( 351 | parse_metadata_date, 352 | parse_metadata_tag_with_value, 353 | parse_comment_with_tags, 354 | )), 355 | ), 356 | ), 357 | Metadata::default, 358 | |meta1, meta2| { 359 | let mut tags = meta1.tags; 360 | tags.extend(meta2.tags); 361 | Metadata { 362 | comment: meta1 363 | .comment 364 | .into_iter() 365 | .chain(meta2.comment) 366 | .reduce(|mut a, b| { 367 | a.push('\n'); 368 | a.push_str(&b); 369 | a 370 | }), 371 | date: meta2.date.or(meta1.date), 372 | effective_date: meta2.effective_date.or(meta1.effective_date), 373 | tags, 374 | } 375 | }, 376 | ), 377 | preceded(space0, eol_or_eof), 378 | )(input) 379 | } 380 | 381 | fn parse_include_file(input: &str) -> LedgerParseResult<&str> { 382 | let (input, _) = delimited(space0, tag("include"), space1)(input)?; 383 | verify( 384 | terminated(not_line_ending, eol_or_eof).map(str::trim_end), 385 | |s: &str| !s.is_empty(), 386 | )(input) 387 | } 388 | 389 | fn take_until_hard_separator(input: &str) -> LedgerParseResult<&str> { 390 | let mut second_space = false; 391 | for (pos, c) in input.char_indices() { 392 | if c == '\t' || c == '\r' || c == '\n' { 393 | if pos > 0 { 394 | let (found, rest) = if second_space { 395 | input.split_at(pos - 1) 396 | } else { 397 | input.split_at(pos) 398 | }; 399 | return Ok((rest, found)); 400 | } else { 401 | return Err(Err::Incomplete(Needed::new(1))); 402 | } 403 | } 404 | 405 | if c == ' ' { 406 | if second_space { 407 | let (found, rest) = input.split_at(pos - 1); 408 | return Ok((rest, found)); 409 | } else { 410 | second_space = true; 411 | } 412 | } else { 413 | second_space = false; 414 | 415 | if pos == input.len() - 1 && pos > 0 { 416 | return Ok(("", input)); 417 | } 418 | } 419 | } 420 | 421 | Err(Err::Incomplete(Needed::new(1))) 422 | } 423 | 424 | fn parse_account(input: &str) -> LedgerParseResult<(&str, Reality)> { 425 | let (input, name) = take_until_hard_separator(input)?; 426 | 427 | if let Some(n1) = name.strip_prefix('[') { 428 | if let Some(n2) = n1.strip_suffix(']') { 429 | return Ok((input, (n2, Reality::BalancedVirtual))); 430 | } 431 | } 432 | 433 | if let Some(n1) = name.strip_prefix('(') { 434 | if let Some(n2) = n1.strip_suffix(')') { 435 | return Ok((input, (n2, Reality::UnbalancedVirtual))); 436 | } 437 | } 438 | 439 | Ok((input, (name, Reality::Real))) 440 | } 441 | 442 | fn parse_transaction_status(input: &str) -> LedgerParseResult { 443 | alt(( 444 | value(TransactionStatus::Cleared, char('*')), 445 | value(TransactionStatus::Pending, char('!')), 446 | ))(input) 447 | } 448 | 449 | fn parse_posting(input: &str) -> LedgerParseResult { 450 | let (input, _) = space1(input)?; 451 | let (input, status) = opt(parse_transaction_status)(input)?; 452 | let (input, _) = space0(input)?; 453 | let (input, (account, reality)) = parse_account(input)?; 454 | let (input, amount) = opt(preceded(space0, parse_posting_amount))(input)?; 455 | let (input, balance) = opt(preceded( 456 | delimited(space0, char('='), space0), 457 | parse_balance, 458 | ))(input)?; 459 | 460 | let ( 461 | input, 462 | Metadata { 463 | comment, 464 | date, 465 | effective_date, 466 | tags, 467 | }, 468 | ) = parse_metadata_comments(input)?; 469 | 470 | Ok(( 471 | input, 472 | Posting { 473 | account: account.to_owned(), 474 | reality, 475 | amount, 476 | balance, 477 | status, 478 | comment, 479 | metadata: PostingMetadata { 480 | date, 481 | effective_date, 482 | tags, 483 | }, 484 | }, 485 | )) 486 | } 487 | 488 | fn parse_payee(input: &str) -> LedgerParseResult<&str> { 489 | alt(( 490 | terminated(take_until_hard_separator, peek(pair(space1, char(';')))), 491 | not_line_ending, 492 | ))(input) 493 | } 494 | 495 | fn parse_transaction(input: &str) -> LedgerParseResult { 496 | let (input, date) = parse_date(input)?; 497 | let (input, effective_date) = opt(preceded(char('='), parse_date))(input)?; 498 | let (input, status) = opt(preceded(space1, parse_transaction_status))(input)?; 499 | let (input, code) = opt(preceded( 500 | space1, 501 | delimited(char('('), is_not(")"), char(')')), 502 | ))(input)?; 503 | let (input, description) = opt(preceded(space1, parse_payee))(input)?; 504 | 505 | let ( 506 | input, 507 | Metadata { 508 | comment, 509 | date: posting_date, 510 | effective_date: posting_effective_date, 511 | tags, 512 | }, 513 | ) = parse_metadata_comments(input)?; 514 | let (input, postings) = many1(parse_posting)(input)?; 515 | 516 | Ok(( 517 | input, 518 | Transaction { 519 | comment, 520 | date, 521 | effective_date, 522 | status, 523 | code: code.map(str::to_owned), 524 | description: description.map(str::to_owned), 525 | posting_metadata: PostingMetadata { 526 | date: posting_date, 527 | effective_date: posting_effective_date, 528 | tags, 529 | }, 530 | postings, 531 | }, 532 | )) 533 | } 534 | 535 | fn parse_ledger_item(input: &str) -> LedgerParseResult { 536 | alt(( 537 | value(LedgerItem::EmptyLine, parse_empty_line), 538 | parse_global_line_comment 539 | .map(str::to_owned) 540 | .map(LedgerItem::LineComment), 541 | parse_transaction.map(LedgerItem::Transaction), 542 | parse_commodity_price.map(LedgerItem::CommodityPrice), 543 | parse_include_file 544 | .map(str::to_owned) 545 | .map(LedgerItem::Include), 546 | ))(input) 547 | } 548 | 549 | pub fn parse_ledger(input: &str) -> LedgerParseResult { 550 | let (input, items) = many0(parse_ledger_item)(input)?; 551 | let (input, _) = eof(input)?; 552 | 553 | Ok((input, Ledger { items })) 554 | } 555 | 556 | #[cfg(test)] 557 | mod tests { 558 | use super::*; 559 | use nom::{ 560 | error::{ErrorKind, ParseError}, 561 | Err::Error, 562 | }; 563 | 564 | #[test] 565 | fn parse_date_test() { 566 | assert_eq!( 567 | parse_date("2017-03-24"), 568 | Ok(("", NaiveDate::from_ymd_opt(2017, 3, 24).unwrap())) 569 | ); 570 | assert_eq!( 571 | parse_date("2017/03/24"), 572 | Ok(("", NaiveDate::from_ymd_opt(2017, 3, 24).unwrap())) 573 | ); 574 | assert_eq!( 575 | parse_date("2017.03.24"), 576 | Ok(("", NaiveDate::from_ymd_opt(2017, 3, 24).unwrap())) 577 | ); 578 | assert_eq!( 579 | parse_date("2017-13-24"), 580 | Err(Error(ParseError::from_error_kind( 581 | "2017-13-24", 582 | ErrorKind::MapOpt 583 | ))) 584 | ); 585 | } 586 | 587 | #[test] 588 | fn parse_datetime_test() { 589 | assert_eq!( 590 | parse_datetime("2017-03-24 17:15:23"), 591 | Ok(( 592 | "", 593 | NaiveDate::from_ymd_opt(2017, 3, 24) 594 | .unwrap() 595 | .and_hms_opt(17, 15, 23) 596 | .unwrap() 597 | )) 598 | ); 599 | assert_eq!( 600 | parse_datetime("2017-13-24 22:11:22"), 601 | Err(Error(ParseError::from_error_kind( 602 | "2017-13-24 22:11:22", 603 | ErrorKind::MapOpt 604 | ))) 605 | ); 606 | assert_eq!( 607 | parse_datetime("2017-03-24 25:11:22"), 608 | Err(Error(ParseError::from_error_kind( 609 | "2017-03-24 25:11:22", 610 | ErrorKind::MapOpt 611 | ))) 612 | ); 613 | } 614 | 615 | #[test] 616 | fn parse_quantity_test() { 617 | assert_eq!(parse_quantity("1000"), Ok(("", Decimal::new(1000, 0)))); 618 | assert_eq!(parse_quantity("2.02"), Ok(("", Decimal::new(202, 2)))); 619 | assert_eq!(parse_quantity("-12.13"), Ok(("", Decimal::new(-1213, 2)))); 620 | assert_eq!(parse_quantity("0.1"), Ok(("", Decimal::new(1, 1)))); 621 | assert_eq!(parse_quantity("3"), Ok(("", Decimal::new(3, 0)))); 622 | assert_eq!(parse_quantity("1"), Ok(("", Decimal::new(1, 0)))); 623 | assert_eq!(parse_quantity("1,000"), Ok(("", Decimal::new(1000, 0)))); 624 | assert_eq!( 625 | parse_quantity("12,456,132.14"), 626 | Ok(("", Decimal::new(1245613214, 2))) 627 | ); 628 | } 629 | 630 | #[test] 631 | fn parse_commodity_test() { 632 | assert_eq!( 633 | parse_commodity("\"ABC 123\""), 634 | Ok(("", "ABC 123".to_owned())) 635 | ); 636 | assert_eq!(parse_commodity("ABC "), Ok((" ", "ABC".to_owned()))); 637 | assert_eq!(parse_commodity("$1"), Ok(("1", "$".to_owned()))); 638 | assert_eq!(parse_commodity("€1"), Ok(("1", "€".to_owned()))); 639 | assert_eq!(parse_commodity("€ "), Ok((" ", "€".to_owned()))); 640 | assert_eq!(parse_commodity("€-1"), Ok(("-1", "€".to_owned()))); 641 | } 642 | 643 | #[test] 644 | fn parse_amount_test() { 645 | assert_eq!( 646 | parse_amount("$1.20"), 647 | Ok(( 648 | "", 649 | Amount { 650 | quantity: Decimal::new(120, 2), 651 | commodity: Commodity { 652 | name: "$".to_owned(), 653 | position: CommodityPosition::Left 654 | } 655 | } 656 | )) 657 | ); 658 | assert_eq!( 659 | parse_amount("$-1.20"), 660 | Ok(( 661 | "", 662 | Amount { 663 | quantity: Decimal::new(-120, 2), 664 | commodity: Commodity { 665 | name: "$".to_owned(), 666 | position: CommodityPosition::Left 667 | } 668 | } 669 | )) 670 | ); 671 | assert_eq!( 672 | parse_amount("-$1.20 "), 673 | Ok(( 674 | " ", 675 | Amount { 676 | quantity: Decimal::new(-120, 2), 677 | commodity: Commodity { 678 | name: "$".to_owned(), 679 | position: CommodityPosition::Left 680 | } 681 | } 682 | )) 683 | ); 684 | assert_eq!( 685 | parse_amount("- $ 1.20"), 686 | Ok(( 687 | "", 688 | Amount { 689 | quantity: Decimal::new(-120, 2), 690 | commodity: Commodity { 691 | name: "$".to_owned(), 692 | position: CommodityPosition::Left 693 | } 694 | } 695 | )) 696 | ); 697 | assert_eq!( 698 | parse_amount("1.20USD"), 699 | Ok(( 700 | "", 701 | Amount { 702 | quantity: Decimal::new(120, 2), 703 | commodity: Commodity { 704 | name: "USD".to_owned(), 705 | position: CommodityPosition::Right 706 | } 707 | } 708 | )) 709 | ); 710 | assert_eq!( 711 | parse_amount("1.20USD "), 712 | Ok(( 713 | " ", 714 | Amount { 715 | quantity: Decimal::new(120, 2), 716 | commodity: Commodity { 717 | name: "USD".to_owned(), 718 | position: CommodityPosition::Right 719 | } 720 | } 721 | )) 722 | ); 723 | assert_eq!( 724 | parse_amount("-1.20 USD"), 725 | Ok(( 726 | "", 727 | Amount { 728 | quantity: Decimal::new(-120, 2), 729 | commodity: Commodity { 730 | name: "USD".to_owned(), 731 | position: CommodityPosition::Right 732 | } 733 | } 734 | )) 735 | ); 736 | } 737 | 738 | #[test] 739 | fn parse_lot_price_test() { 740 | assert_eq!( 741 | parse_lot_price("{$1.20}"), 742 | Ok(( 743 | "", 744 | Price::Unit(Amount { 745 | quantity: Decimal::new(120, 2), 746 | commodity: Commodity { 747 | name: "$".to_owned(), 748 | position: CommodityPosition::Left 749 | } 750 | }) 751 | )) 752 | ); 753 | assert_eq!( 754 | parse_lot_price("{ $1.20 }"), 755 | Ok(( 756 | "", 757 | Price::Unit(Amount { 758 | quantity: Decimal::new(120, 2), 759 | commodity: Commodity { 760 | name: "$".to_owned(), 761 | position: CommodityPosition::Left 762 | } 763 | }) 764 | )) 765 | ); 766 | assert_eq!( 767 | parse_lot_price("{1.20PLN}"), 768 | Ok(( 769 | "", 770 | Price::Unit(Amount { 771 | quantity: Decimal::new(120, 2), 772 | commodity: Commodity { 773 | name: "PLN".to_owned(), 774 | position: CommodityPosition::Right 775 | } 776 | }) 777 | )) 778 | ); 779 | assert_eq!( 780 | parse_lot_price("{ 1.20 PLN } "), 781 | Ok(( 782 | " ", 783 | Price::Unit(Amount { 784 | quantity: Decimal::new(120, 2), 785 | commodity: Commodity { 786 | name: "PLN".to_owned(), 787 | position: CommodityPosition::Right 788 | } 789 | }) 790 | )) 791 | ); 792 | } 793 | 794 | #[test] 795 | fn parse_price_test() { 796 | assert_eq!( 797 | parse_price("@$1.20"), 798 | Ok(( 799 | "", 800 | Price::Unit(Amount { 801 | quantity: Decimal::new(120, 2), 802 | commodity: Commodity { 803 | name: "$".to_owned(), 804 | position: CommodityPosition::Left 805 | } 806 | }) 807 | )) 808 | ); 809 | assert_eq!( 810 | parse_price("@ $1.20"), 811 | Ok(( 812 | "", 813 | Price::Unit(Amount { 814 | quantity: Decimal::new(120, 2), 815 | commodity: Commodity { 816 | name: "$".to_owned(), 817 | position: CommodityPosition::Left 818 | } 819 | }) 820 | )) 821 | ); 822 | assert_eq!( 823 | parse_price("@@1.20PLN"), 824 | Ok(( 825 | "", 826 | Price::Total(Amount { 827 | quantity: Decimal::new(120, 2), 828 | commodity: Commodity { 829 | name: "PLN".to_owned(), 830 | position: CommodityPosition::Right 831 | } 832 | }) 833 | )) 834 | ); 835 | assert_eq!( 836 | parse_price("@@ 1.20 PLN "), 837 | Ok(( 838 | " ", 839 | Price::Total(Amount { 840 | quantity: Decimal::new(120, 2), 841 | commodity: Commodity { 842 | name: "PLN".to_owned(), 843 | position: CommodityPosition::Right 844 | } 845 | }) 846 | )) 847 | ); 848 | } 849 | 850 | #[test] 851 | fn parse_posting_amount_test() { 852 | assert_eq!( 853 | parse_posting_amount("$1.20"), 854 | Ok(( 855 | "", 856 | PostingAmount { 857 | amount: Amount { 858 | quantity: Decimal::new(120, 2), 859 | commodity: Commodity { 860 | name: "$".to_owned(), 861 | position: CommodityPosition::Left 862 | } 863 | }, 864 | lot_price: None, 865 | price: None 866 | } 867 | )) 868 | ); 869 | assert_eq!( 870 | parse_posting_amount("$1.20 @ 5.00 PLN"), 871 | Ok(( 872 | "", 873 | PostingAmount { 874 | amount: Amount { 875 | quantity: Decimal::new(120, 2), 876 | commodity: Commodity { 877 | name: "$".to_owned(), 878 | position: CommodityPosition::Left 879 | } 880 | }, 881 | lot_price: None, 882 | price: Some(Price::Unit(Amount { 883 | quantity: Decimal::new(500, 2), 884 | commodity: Commodity { 885 | name: "PLN".to_owned(), 886 | position: CommodityPosition::Right 887 | } 888 | })) 889 | } 890 | )) 891 | ); 892 | assert_eq!( 893 | parse_posting_amount("$1.20 {5.00 PLN}"), 894 | Ok(( 895 | "", 896 | PostingAmount { 897 | amount: Amount { 898 | quantity: Decimal::new(120, 2), 899 | commodity: Commodity { 900 | name: "$".to_owned(), 901 | position: CommodityPosition::Left 902 | } 903 | }, 904 | lot_price: Some(Price::Unit(Amount { 905 | quantity: Decimal::new(500, 2), 906 | commodity: Commodity { 907 | name: "PLN".to_owned(), 908 | position: CommodityPosition::Right 909 | } 910 | })), 911 | price: None, 912 | } 913 | )) 914 | ); 915 | assert_eq!( 916 | parse_posting_amount("$1.20 {{5.00 PLN}} @@6.0PLN "), 917 | Ok(( 918 | " ", 919 | PostingAmount { 920 | amount: Amount { 921 | quantity: Decimal::new(120, 2), 922 | commodity: Commodity { 923 | name: "$".to_owned(), 924 | position: CommodityPosition::Left 925 | } 926 | }, 927 | lot_price: Some(Price::Total(Amount { 928 | quantity: Decimal::new(500, 2), 929 | commodity: Commodity { 930 | name: "PLN".to_owned(), 931 | position: CommodityPosition::Right 932 | } 933 | })), 934 | price: Some(Price::Total(Amount { 935 | quantity: Decimal::new(600, 2), 936 | commodity: Commodity { 937 | name: "PLN".to_owned(), 938 | position: CommodityPosition::Right 939 | } 940 | })), 941 | } 942 | )) 943 | ); 944 | } 945 | 946 | #[test] 947 | fn parse_balance_test() { 948 | assert_eq!( 949 | parse_balance("$1.20"), 950 | Ok(( 951 | "", 952 | Balance::Amount(Amount { 953 | quantity: Decimal::new(120, 2), 954 | commodity: Commodity { 955 | name: "$".to_owned(), 956 | position: CommodityPosition::Left 957 | } 958 | }) 959 | )) 960 | ); 961 | assert_eq!( 962 | parse_balance("0 PLN"), 963 | Ok(( 964 | "", 965 | Balance::Amount(Amount { 966 | quantity: Decimal::new(0, 0), 967 | commodity: Commodity { 968 | name: "PLN".to_owned(), 969 | position: CommodityPosition::Right 970 | } 971 | }) 972 | )) 973 | ); 974 | assert_eq!(parse_balance("0"), Ok(("", Balance::Zero))); 975 | } 976 | 977 | #[test] 978 | fn parse_commodity_price_test() { 979 | assert_eq!( 980 | parse_commodity_price("P 2017-11-12 12:00:00 mBH 5.00 PLN"), 981 | Ok(( 982 | "", 983 | CommodityPrice { 984 | datetime: NaiveDate::from_ymd_opt(2017, 11, 12) 985 | .unwrap() 986 | .and_hms_opt(12, 00, 00) 987 | .unwrap(), 988 | commodity_name: "mBH".to_owned(), 989 | amount: Amount { 990 | quantity: Decimal::new(500, 2), 991 | commodity: Commodity { 992 | name: "PLN".to_owned(), 993 | position: CommodityPosition::Right 994 | } 995 | } 996 | } 997 | )) 998 | ); 999 | } 1000 | 1001 | #[test] 1002 | fn parse_account_test() { 1003 | assert_eq!( 1004 | parse_account("TEST:ABC 123 "), 1005 | Ok((" ", ("TEST:ABC 123", Reality::Real))) 1006 | ); 1007 | assert_eq!( 1008 | parse_account("TEST:ABC 123\t"), 1009 | Ok(("\t", ("TEST:ABC 123", Reality::Real))) 1010 | ); 1011 | assert_eq!( 1012 | parse_account("TEST:ABC 123"), 1013 | Ok(("", ("TEST:ABC 123", Reality::Real))) 1014 | ); 1015 | assert_eq!( 1016 | parse_account("[TEST:ABC 123]"), 1017 | Ok(("", ("TEST:ABC 123", Reality::BalancedVirtual))) 1018 | ); 1019 | assert_eq!( 1020 | parse_account("(TEST:ABC 123)"), 1021 | Ok(("", ("TEST:ABC 123", Reality::UnbalancedVirtual))) 1022 | ); 1023 | } 1024 | 1025 | #[test] 1026 | fn parse_transaction_status_test() { 1027 | assert_eq!( 1028 | parse_transaction_status("!"), 1029 | Ok(("", TransactionStatus::Pending)) 1030 | ); 1031 | assert_eq!( 1032 | parse_transaction_status("*"), 1033 | Ok(("", TransactionStatus::Cleared)) 1034 | ); 1035 | } 1036 | 1037 | #[test] 1038 | fn parse_posting_test() { 1039 | assert_eq!( 1040 | parse_posting(" TEST:ABC 123 $1.20"), 1041 | Ok(( 1042 | "", 1043 | Posting { 1044 | account: "TEST:ABC 123".to_owned(), 1045 | reality: Reality::Real, 1046 | amount: Some(PostingAmount { 1047 | amount: Amount { 1048 | quantity: Decimal::new(120, 2), 1049 | commodity: Commodity { 1050 | name: "$".to_owned(), 1051 | position: CommodityPosition::Left 1052 | } 1053 | }, 1054 | lot_price: None, 1055 | price: None 1056 | }), 1057 | balance: None, 1058 | status: None, 1059 | comment: None, 1060 | metadata: PostingMetadata { 1061 | date: None, 1062 | effective_date: None, 1063 | tags: vec![], 1064 | }, 1065 | } 1066 | )) 1067 | ); 1068 | assert_eq!( 1069 | parse_posting(" ! TEST:ABC 123 $1.20;test\n;comment line 2"), 1070 | Ok(( 1071 | "", 1072 | Posting { 1073 | account: "TEST:ABC 123".to_owned(), 1074 | reality: Reality::Real, 1075 | amount: Some(PostingAmount { 1076 | amount: Amount { 1077 | quantity: Decimal::new(120, 2), 1078 | commodity: Commodity { 1079 | name: "$".to_owned(), 1080 | position: CommodityPosition::Left 1081 | } 1082 | }, 1083 | lot_price: None, 1084 | price: None 1085 | }), 1086 | balance: None, 1087 | status: Some(TransactionStatus::Pending), 1088 | comment: Some("test\ncomment line 2".to_owned()), 1089 | metadata: PostingMetadata { 1090 | date: None, 1091 | effective_date: None, 1092 | tags: vec![], 1093 | }, 1094 | } 1095 | )) 1096 | ); 1097 | assert_eq!( 1098 | parse_posting(" ! TEST:ABC 123;test\n;comment"), 1099 | Ok(( 1100 | "", 1101 | Posting { 1102 | account: "TEST:ABC 123;test".to_owned(), 1103 | reality: Reality::Real, 1104 | amount: None, 1105 | balance: None, 1106 | status: Some(TransactionStatus::Pending), 1107 | comment: Some("comment".to_owned()), 1108 | metadata: PostingMetadata { 1109 | date: None, 1110 | effective_date: None, 1111 | tags: vec![], 1112 | }, 1113 | } 1114 | )) 1115 | ); 1116 | assert_eq!( 1117 | parse_posting(" ! TEST:ABC 123 ;test\n;comment line 2"), 1118 | Ok(( 1119 | "", 1120 | Posting { 1121 | account: "TEST:ABC 123".to_owned(), 1122 | reality: Reality::Real, 1123 | amount: None, 1124 | balance: None, 1125 | status: Some(TransactionStatus::Pending), 1126 | comment: Some("test\ncomment line 2".to_owned()), 1127 | metadata: PostingMetadata { 1128 | date: None, 1129 | effective_date: None, 1130 | tags: vec![], 1131 | }, 1132 | } 1133 | )) 1134 | ); 1135 | assert_eq!( 1136 | parse_posting(" ! TEST:ABC 123 ; test \n ; comment line 2 "), 1137 | Ok(( 1138 | "", 1139 | Posting { 1140 | account: "TEST:ABC 123".to_owned(), 1141 | reality: Reality::Real, 1142 | amount: None, 1143 | balance: None, 1144 | status: Some(TransactionStatus::Pending), 1145 | comment: Some("test\ncomment line 2".to_owned()), 1146 | metadata: PostingMetadata { 1147 | date: None, 1148 | effective_date: None, 1149 | tags: vec![], 1150 | }, 1151 | } 1152 | )) 1153 | ); 1154 | assert_eq!( 1155 | parse_posting(" TEST:ABC 123 $1.20 = $2.40 ;comment"), 1156 | Ok(( 1157 | "", 1158 | Posting { 1159 | account: "TEST:ABC 123".to_owned(), 1160 | reality: Reality::Real, 1161 | amount: Some(PostingAmount { 1162 | amount: Amount { 1163 | quantity: Decimal::new(120, 2), 1164 | commodity: Commodity { 1165 | name: "$".to_owned(), 1166 | position: CommodityPosition::Left 1167 | } 1168 | }, 1169 | lot_price: None, 1170 | price: None 1171 | }), 1172 | balance: Some(Balance::Amount(Amount { 1173 | quantity: Decimal::new(240, 2), 1174 | commodity: Commodity { 1175 | name: "$".to_owned(), 1176 | position: CommodityPosition::Left 1177 | } 1178 | })), 1179 | status: None, 1180 | comment: Some("comment".to_owned()), 1181 | metadata: PostingMetadata { 1182 | date: None, 1183 | effective_date: None, 1184 | tags: vec![], 1185 | }, 1186 | } 1187 | )) 1188 | ); 1189 | assert_eq!( 1190 | parse_posting(" TEST:ABC 123"), 1191 | Ok(( 1192 | "", 1193 | Posting { 1194 | account: "TEST:ABC 123".to_owned(), 1195 | reality: Reality::Real, 1196 | amount: None, 1197 | balance: None, 1198 | status: None, 1199 | comment: None, 1200 | metadata: PostingMetadata { 1201 | date: None, 1202 | effective_date: None, 1203 | tags: vec![], 1204 | }, 1205 | } 1206 | )) 1207 | ); 1208 | assert_eq!( 1209 | parse_posting(" TEST:ABC 123 ; 456"), 1210 | Ok(( 1211 | "", 1212 | Posting { 1213 | account: "TEST:ABC 123".to_owned(), 1214 | reality: Reality::Real, 1215 | amount: None, 1216 | balance: None, 1217 | status: None, 1218 | comment: Some("456".to_owned()), 1219 | metadata: PostingMetadata { 1220 | date: None, 1221 | effective_date: None, 1222 | tags: vec![], 1223 | }, 1224 | } 1225 | )) 1226 | ); 1227 | assert_eq!( 1228 | parse_posting(" TEST:ABC 123 ; [2018-10-01]"), 1229 | Ok(( 1230 | "", 1231 | Posting { 1232 | account: "TEST:ABC 123".to_owned(), 1233 | reality: Reality::Real, 1234 | amount: None, 1235 | balance: None, 1236 | status: None, 1237 | comment: None, 1238 | metadata: PostingMetadata { 1239 | date: Some(NaiveDate::from_ymd_opt(2018, 10, 1).unwrap()), 1240 | effective_date: None, 1241 | tags: vec![], 1242 | }, 1243 | } 1244 | )) 1245 | ); 1246 | assert_eq!( 1247 | parse_posting(" TEST:ABC 123 ; [=2018-10-01]"), 1248 | Ok(( 1249 | "", 1250 | Posting { 1251 | account: "TEST:ABC 123".to_owned(), 1252 | reality: Reality::Real, 1253 | amount: None, 1254 | balance: None, 1255 | status: None, 1256 | comment: None, 1257 | metadata: PostingMetadata { 1258 | date: None, 1259 | effective_date: Some(NaiveDate::from_ymd_opt(2018, 10, 1).unwrap()), 1260 | tags: vec![], 1261 | }, 1262 | } 1263 | )) 1264 | ); 1265 | assert_eq!( 1266 | parse_posting(" TEST:ABC 123 ; [2018-10-01=2018-10-14]"), 1267 | Ok(( 1268 | "", 1269 | Posting { 1270 | account: "TEST:ABC 123".to_owned(), 1271 | reality: Reality::Real, 1272 | amount: None, 1273 | balance: None, 1274 | status: None, 1275 | comment: None, 1276 | metadata: PostingMetadata { 1277 | date: Some(NaiveDate::from_ymd_opt(2018, 10, 1).unwrap()), 1278 | effective_date: Some(NaiveDate::from_ymd_opt(2018, 10, 14).unwrap()), 1279 | tags: vec![], 1280 | }, 1281 | } 1282 | )) 1283 | ); 1284 | assert_eq!( 1285 | parse_posting(" TEST:ABC 123 ; Tag: tag value"), 1286 | Ok(( 1287 | "", 1288 | Posting { 1289 | account: "TEST:ABC 123".to_owned(), 1290 | reality: Reality::Real, 1291 | amount: None, 1292 | balance: None, 1293 | status: None, 1294 | comment: None, 1295 | metadata: PostingMetadata { 1296 | date: None, 1297 | effective_date: None, 1298 | tags: vec![Tag { 1299 | name: "Tag".to_owned(), 1300 | value: Some(TagValue::String("tag value".to_owned())), 1301 | }], 1302 | }, 1303 | } 1304 | )) 1305 | ); 1306 | assert_eq!( 1307 | parse_posting(" TEST:ABC 123 ; :tag1:tag2: "), 1308 | Ok(( 1309 | "", 1310 | Posting { 1311 | account: "TEST:ABC 123".to_owned(), 1312 | reality: Reality::Real, 1313 | amount: None, 1314 | balance: None, 1315 | status: None, 1316 | comment: None, 1317 | metadata: PostingMetadata { 1318 | date: None, 1319 | effective_date: None, 1320 | tags: vec![ 1321 | Tag { 1322 | name: "tag1".to_owned(), 1323 | value: None, 1324 | }, 1325 | Tag { 1326 | name: "tag2".to_owned(), 1327 | value: None, 1328 | } 1329 | ], 1330 | }, 1331 | } 1332 | )) 1333 | ); 1334 | assert_eq!( 1335 | parse_posting(" TEST:ABC 123 ; comment :tag1:tag2: and: more comment"), 1336 | Ok(( 1337 | "", 1338 | Posting { 1339 | account: "TEST:ABC 123".to_owned(), 1340 | reality: Reality::Real, 1341 | amount: None, 1342 | balance: None, 1343 | status: None, 1344 | comment: Some("comment and: more comment".to_owned()), 1345 | metadata: PostingMetadata { 1346 | date: None, 1347 | effective_date: None, 1348 | tags: vec![ 1349 | Tag { 1350 | name: "tag1".to_owned(), 1351 | value: None, 1352 | }, 1353 | Tag { 1354 | name: "tag2".to_owned(), 1355 | value: None, 1356 | } 1357 | ], 1358 | }, 1359 | } 1360 | )) 1361 | ); 1362 | assert_eq!( 1363 | parse_posting(" TEST:ABC 123 ; Tag:: [2018-01-05]"), 1364 | Ok(( 1365 | "", 1366 | Posting { 1367 | account: "TEST:ABC 123".to_owned(), 1368 | reality: Reality::Real, 1369 | amount: None, 1370 | balance: None, 1371 | status: None, 1372 | comment: None, 1373 | metadata: PostingMetadata { 1374 | date: None, 1375 | effective_date: None, 1376 | tags: vec![Tag { 1377 | name: "Tag".to_owned(), 1378 | value: Some(TagValue::Date( 1379 | NaiveDate::from_ymd_opt(2018, 1, 5).unwrap() 1380 | )) 1381 | }], 1382 | }, 1383 | } 1384 | )) 1385 | ); 1386 | assert_eq!( 1387 | parse_posting(" TEST:ABC 123 ; TheAnswer:: 42"), 1388 | Ok(( 1389 | "", 1390 | Posting { 1391 | account: "TEST:ABC 123".to_owned(), 1392 | reality: Reality::Real, 1393 | amount: None, 1394 | balance: None, 1395 | status: None, 1396 | comment: None, 1397 | metadata: PostingMetadata { 1398 | date: None, 1399 | effective_date: None, 1400 | tags: vec![Tag { 1401 | name: "TheAnswer".to_owned(), 1402 | value: Some(TagValue::Integer(42)) 1403 | }], 1404 | }, 1405 | } 1406 | )) 1407 | ); 1408 | assert_eq!( 1409 | parse_posting(" TEST:ABC 123 ; ISquared:: -1"), 1410 | Ok(( 1411 | "", 1412 | Posting { 1413 | account: "TEST:ABC 123".to_owned(), 1414 | reality: Reality::Real, 1415 | amount: None, 1416 | balance: None, 1417 | status: None, 1418 | comment: None, 1419 | metadata: PostingMetadata { 1420 | date: None, 1421 | effective_date: None, 1422 | tags: vec![Tag { 1423 | name: "ISquared".to_owned(), 1424 | value: Some(TagValue::Integer(-1)) 1425 | }], 1426 | }, 1427 | } 1428 | )) 1429 | ); 1430 | assert_eq!( 1431 | parse_posting(" TEST:ABC 123 ; Pi:: 3.141592653589793"), 1432 | Ok(( 1433 | "", 1434 | Posting { 1435 | account: "TEST:ABC 123".to_owned(), 1436 | reality: Reality::Real, 1437 | amount: None, 1438 | balance: None, 1439 | status: None, 1440 | comment: None, 1441 | metadata: PostingMetadata { 1442 | date: None, 1443 | effective_date: None, 1444 | tags: vec![Tag { 1445 | name: "Pi".to_owned(), 1446 | value: Some(TagValue::Float( 1447 | NotNan::new(std::f64::consts::PI).unwrap() 1448 | )) 1449 | }], 1450 | }, 1451 | } 1452 | )) 1453 | ); 1454 | } 1455 | 1456 | #[test] 1457 | fn parse_transaction_test() { 1458 | assert_eq!( 1459 | parse_transaction( 1460 | r#"2018-10-01=2018-10-14 ! (123) Marek Ogarek ; Transaction comment 1461 | TEST:ABC 123 $1.20 ; Posting comment 1462 | ; over two lines 1463 | TEST:ABC 123 $1.20"# 1464 | ), 1465 | Ok(( 1466 | "", 1467 | Transaction { 1468 | comment: Some("Transaction comment".to_owned()), 1469 | date: NaiveDate::from_ymd_opt(2018, 10, 1).unwrap(), 1470 | effective_date: Some(NaiveDate::from_ymd_opt(2018, 10, 14).unwrap()), 1471 | posting_metadata: PostingMetadata { 1472 | date: None, 1473 | effective_date: None, 1474 | tags: vec![], 1475 | }, 1476 | status: Some(TransactionStatus::Pending), 1477 | code: Some("123".to_owned()), 1478 | description: Some("Marek Ogarek".to_owned()), 1479 | postings: vec![ 1480 | Posting { 1481 | account: "TEST:ABC 123".to_owned(), 1482 | reality: Reality::Real, 1483 | amount: Some(PostingAmount { 1484 | amount: Amount { 1485 | quantity: Decimal::new(120, 2), 1486 | commodity: Commodity { 1487 | name: "$".to_owned(), 1488 | position: CommodityPosition::Left 1489 | } 1490 | }, 1491 | lot_price: None, 1492 | price: None 1493 | }), 1494 | balance: None, 1495 | status: None, 1496 | comment: Some("Posting comment\nover two lines".to_owned()), 1497 | metadata: PostingMetadata { 1498 | date: None, 1499 | effective_date: None, 1500 | tags: vec![], 1501 | }, 1502 | }, 1503 | Posting { 1504 | account: "TEST:ABC 123".to_owned(), 1505 | reality: Reality::Real, 1506 | amount: Some(PostingAmount { 1507 | amount: Amount { 1508 | quantity: Decimal::new(120, 2), 1509 | commodity: Commodity { 1510 | name: "$".to_owned(), 1511 | position: CommodityPosition::Left 1512 | } 1513 | }, 1514 | lot_price: None, 1515 | price: None 1516 | }), 1517 | balance: None, 1518 | status: None, 1519 | comment: None, 1520 | metadata: PostingMetadata { 1521 | date: None, 1522 | effective_date: None, 1523 | tags: vec![], 1524 | }, 1525 | } 1526 | ] 1527 | } 1528 | )) 1529 | ); 1530 | assert_eq!( 1531 | parse_transaction( 1532 | r#"2018-10-01=2018-10-14 Marek Ogarek ; one space 1533 | TEST:ABC 123 $1.20 ; test 1534 | TEST:DEF 123 EUR-1.20 1535 | TEST:GHI 123 1536 | TEST:JKL 123 EUR-2.00"# 1537 | ), 1538 | Ok(( 1539 | "", 1540 | Transaction { 1541 | comment: None, 1542 | date: NaiveDate::from_ymd_opt(2018, 10, 1).unwrap(), 1543 | effective_date: Some(NaiveDate::from_ymd_opt(2018, 10, 14).unwrap()), 1544 | posting_metadata: PostingMetadata { 1545 | date: None, 1546 | effective_date: None, 1547 | tags: vec![], 1548 | }, 1549 | status: None, 1550 | code: None, 1551 | description: Some("Marek Ogarek ; one space".to_owned()), 1552 | postings: vec![ 1553 | Posting { 1554 | account: "TEST:ABC 123".to_owned(), 1555 | reality: Reality::Real, 1556 | amount: Some(PostingAmount { 1557 | amount: Amount { 1558 | quantity: Decimal::new(120, 2), 1559 | commodity: Commodity { 1560 | name: "$".to_owned(), 1561 | position: CommodityPosition::Left 1562 | } 1563 | }, 1564 | lot_price: None, 1565 | price: None 1566 | }), 1567 | balance: None, 1568 | status: None, 1569 | comment: Some("test".to_owned()), 1570 | metadata: PostingMetadata { 1571 | date: None, 1572 | effective_date: None, 1573 | tags: vec![], 1574 | }, 1575 | }, 1576 | Posting { 1577 | balance: None, 1578 | account: "TEST:DEF 123".to_owned(), 1579 | reality: Reality::Real, 1580 | amount: Some(PostingAmount { 1581 | amount: Amount { 1582 | quantity: Decimal::new(-120, 2), 1583 | commodity: Commodity { 1584 | name: "EUR".to_owned(), 1585 | position: CommodityPosition::Left 1586 | } 1587 | }, 1588 | lot_price: None, 1589 | price: None 1590 | }), 1591 | status: None, 1592 | comment: None, 1593 | metadata: PostingMetadata { 1594 | date: None, 1595 | effective_date: None, 1596 | tags: vec![], 1597 | }, 1598 | }, 1599 | Posting { 1600 | account: "TEST:GHI 123".to_owned(), 1601 | reality: Reality::Real, 1602 | amount: None, 1603 | balance: None, 1604 | status: None, 1605 | comment: None, 1606 | metadata: PostingMetadata { 1607 | date: None, 1608 | effective_date: None, 1609 | tags: vec![], 1610 | }, 1611 | }, 1612 | Posting { 1613 | account: "TEST:JKL 123".to_owned(), 1614 | reality: Reality::Real, 1615 | amount: Some(PostingAmount { 1616 | amount: Amount { 1617 | quantity: Decimal::new(-200, 2), 1618 | commodity: Commodity { 1619 | name: "EUR".to_owned(), 1620 | position: CommodityPosition::Left 1621 | } 1622 | }, 1623 | lot_price: None, 1624 | price: None 1625 | }), 1626 | balance: None, 1627 | status: None, 1628 | comment: None, 1629 | metadata: PostingMetadata { 1630 | date: None, 1631 | effective_date: None, 1632 | tags: vec![], 1633 | }, 1634 | }, 1635 | ] 1636 | } 1637 | )) 1638 | ); 1639 | assert_eq!( 1640 | parse_transaction( 1641 | r#"2018-10-01=2018-10-14 ! (123) Marek Ogarek two spaces 1642 | TEST:ABC 123 $1.20 ; test 1643 | TEST:DEF 123"# 1644 | ), 1645 | Ok(( 1646 | "", 1647 | Transaction { 1648 | comment: None, 1649 | date: NaiveDate::from_ymd_opt(2018, 10, 1).unwrap(), 1650 | effective_date: Some(NaiveDate::from_ymd_opt(2018, 10, 14).unwrap()), 1651 | posting_metadata: PostingMetadata { 1652 | date: None, 1653 | effective_date: None, 1654 | tags: vec![], 1655 | }, 1656 | status: Some(TransactionStatus::Pending), 1657 | code: Some("123".to_owned()), 1658 | description: Some("Marek Ogarek two spaces".to_owned()), 1659 | postings: vec![ 1660 | Posting { 1661 | account: "TEST:ABC 123".to_owned(), 1662 | reality: Reality::Real, 1663 | amount: Some(PostingAmount { 1664 | amount: Amount { 1665 | quantity: Decimal::new(120, 2), 1666 | commodity: Commodity { 1667 | name: "$".to_owned(), 1668 | position: CommodityPosition::Left 1669 | } 1670 | }, 1671 | lot_price: None, 1672 | price: None 1673 | }), 1674 | balance: None, 1675 | status: None, 1676 | comment: Some("test".to_owned()), 1677 | metadata: PostingMetadata { 1678 | date: None, 1679 | effective_date: None, 1680 | tags: vec![], 1681 | }, 1682 | }, 1683 | Posting { 1684 | account: "TEST:DEF 123".to_owned(), 1685 | reality: Reality::Real, 1686 | amount: None, 1687 | balance: None, 1688 | status: None, 1689 | comment: None, 1690 | metadata: PostingMetadata { 1691 | date: None, 1692 | effective_date: None, 1693 | tags: vec![], 1694 | }, 1695 | }, 1696 | ] 1697 | } 1698 | )) 1699 | ); 1700 | 1701 | // same transaction, but no payee/description (these are optional in ledger) 1702 | assert_eq!( 1703 | parse_transaction( 1704 | r#"2018-10-01=2018-10-14 ! (123) 1705 | TEST:ABC 123 $1.20 1706 | TEST:DEF 123"# 1707 | ), 1708 | Ok(( 1709 | "", 1710 | Transaction { 1711 | comment: None, 1712 | date: NaiveDate::from_ymd_opt(2018, 10, 1).unwrap(), 1713 | effective_date: Some(NaiveDate::from_ymd_opt(2018, 10, 14).unwrap()), 1714 | posting_metadata: PostingMetadata { 1715 | date: None, 1716 | effective_date: None, 1717 | tags: vec![], 1718 | }, 1719 | status: Some(TransactionStatus::Pending), 1720 | code: Some("123".to_owned()), 1721 | description: None, 1722 | postings: vec![ 1723 | Posting { 1724 | account: "TEST:ABC 123".to_owned(), 1725 | reality: Reality::Real, 1726 | amount: Some(PostingAmount { 1727 | amount: Amount { 1728 | quantity: Decimal::new(120, 2), 1729 | commodity: Commodity { 1730 | name: "$".to_owned(), 1731 | position: CommodityPosition::Left 1732 | } 1733 | }, 1734 | lot_price: None, 1735 | price: None 1736 | }), 1737 | balance: None, 1738 | status: None, 1739 | comment: None, 1740 | metadata: PostingMetadata { 1741 | date: None, 1742 | effective_date: None, 1743 | tags: vec![], 1744 | }, 1745 | }, 1746 | Posting { 1747 | account: "TEST:DEF 123".to_owned(), 1748 | reality: Reality::Real, 1749 | amount: None, 1750 | balance: None, 1751 | status: None, 1752 | comment: None, 1753 | metadata: PostingMetadata { 1754 | date: None, 1755 | effective_date: None, 1756 | tags: vec![], 1757 | }, 1758 | }, 1759 | ] 1760 | } 1761 | )) 1762 | ); 1763 | } 1764 | 1765 | #[test] 1766 | fn parse_include_test() { 1767 | assert_eq!( 1768 | parse_include_file(r#"include other_file.ledger"#), 1769 | Ok(("", "other_file.ledger")) 1770 | ); 1771 | } 1772 | 1773 | #[test] 1774 | fn parse_ledger_test() { 1775 | let res = parse_ledger( 1776 | r#"; Example 1 1777 | 1778 | include other_file.ledger 1779 | 1780 | P 2017-11-12 12:00:00 mBH 5.00 PLN ; comment 1781 | 1782 | ; Comment 1783 | 2018-10-01=2018-10-14 ! (123) Marek Ogarek 1784 | TEST:ABC 123 $1.20 1785 | TEST:ABC 123 $1.20 1786 | 1787 | 2018-10-01=2018-10-14 ! (123) Marek Ogarek 1788 | TEST:ABC 123 $1.20 1789 | TEST:ABC 123 $1.20 1790 | "#, 1791 | ) 1792 | .unwrap() 1793 | .1; 1794 | assert_eq!(res.items.len(), 10); 1795 | assert!(matches!(res.items[0], LedgerItem::LineComment(_))); 1796 | assert!(matches!(res.items[1], LedgerItem::EmptyLine)); 1797 | assert!(matches!(res.items[2], LedgerItem::Include(_))); 1798 | assert!(matches!(res.items[3], LedgerItem::EmptyLine)); 1799 | assert!(matches!(res.items[4], LedgerItem::CommodityPrice(_))); 1800 | assert!(matches!(res.items[5], LedgerItem::EmptyLine)); 1801 | assert!(matches!(res.items[6], LedgerItem::LineComment(_))); 1802 | assert!(matches!(res.items[7], LedgerItem::Transaction(_))); 1803 | assert!(matches!(res.items[8], LedgerItem::EmptyLine)); 1804 | assert!(matches!(res.items[9], LedgerItem::Transaction(_))); 1805 | } 1806 | } 1807 | -------------------------------------------------------------------------------- /src/serializer.rs: -------------------------------------------------------------------------------- 1 | use crate::model::*; 2 | use std::io; 3 | 4 | #[non_exhaustive] 5 | pub struct SerializerSettings { 6 | pub indent: String, 7 | pub eol: String, 8 | 9 | pub transaction_date_format: String, 10 | pub commodity_date_format: String, 11 | 12 | /// Should single line posting comments be printed on the same line as the posting? 13 | pub posting_comments_sameline: bool, 14 | } 15 | 16 | impl SerializerSettings { 17 | pub fn with_indent(mut self, indent: &str) -> Self { 18 | indent.clone_into(&mut self.indent); 19 | self 20 | } 21 | 22 | pub fn with_eol(mut self, eol: &str) -> Self { 23 | eol.clone_into(&mut self.eol); 24 | self 25 | } 26 | } 27 | 28 | impl Default for SerializerSettings { 29 | fn default() -> Self { 30 | Self { 31 | indent: " ".to_owned(), 32 | eol: "\n".to_owned(), 33 | transaction_date_format: "%Y-%m-%d".to_owned(), 34 | commodity_date_format: "%Y-%m-%d %H:%M:%S".to_owned(), 35 | posting_comments_sameline: false, 36 | } 37 | } 38 | } 39 | 40 | pub trait Serializer { 41 | fn write(&self, writer: &mut W, settings: &SerializerSettings) -> Result<(), io::Error> 42 | where 43 | W: io::Write; 44 | 45 | fn to_string_pretty(&self, settings: &SerializerSettings) -> String { 46 | let mut res = Vec::new(); 47 | self.write(&mut res, settings).unwrap(); 48 | return std::str::from_utf8(&res).unwrap().to_owned(); 49 | } 50 | } 51 | 52 | impl Serializer for Ledger { 53 | fn write(&self, writer: &mut W, settings: &SerializerSettings) -> Result<(), io::Error> 54 | where 55 | W: io::Write, 56 | { 57 | for item in &self.items { 58 | item.write(writer, settings)?; 59 | } 60 | Ok(()) 61 | } 62 | } 63 | 64 | impl Serializer for LedgerItem { 65 | fn write(&self, writer: &mut W, settings: &SerializerSettings) -> Result<(), io::Error> 66 | where 67 | W: io::Write, 68 | { 69 | match self { 70 | LedgerItem::EmptyLine => write!(writer, "{}", settings.eol)?, 71 | LedgerItem::LineComment(comment) => write!(writer, "; {}{}", comment, settings.eol)?, 72 | LedgerItem::Transaction(transaction) => { 73 | transaction.write(writer, settings)?; 74 | write!(writer, "{}", settings.eol)?; 75 | } 76 | LedgerItem::CommodityPrice(commodity_price) => { 77 | commodity_price.write(writer, settings)?; 78 | write!(writer, "{}", settings.eol)?; 79 | } 80 | LedgerItem::Include(file) => write!(writer, "include {}{}", file, settings.eol)?, 81 | } 82 | Ok(()) 83 | } 84 | } 85 | 86 | impl Serializer for Transaction { 87 | fn write(&self, writer: &mut W, settings: &SerializerSettings) -> Result<(), io::Error> 88 | where 89 | W: io::Write, 90 | { 91 | write!( 92 | writer, 93 | "{}", 94 | self.date.format(&settings.transaction_date_format) 95 | )?; 96 | 97 | if let Some(effective_date) = self.effective_date { 98 | write!( 99 | writer, 100 | "={}", 101 | effective_date.format(&settings.transaction_date_format) 102 | )?; 103 | } 104 | 105 | if let Some(ref status) = self.status { 106 | write!(writer, " ")?; 107 | status.write(writer, settings)?; 108 | } 109 | 110 | if let Some(ref code) = self.code { 111 | write!(writer, " ({})", code)?; 112 | } 113 | 114 | // for the None case, ledger would print "" 115 | if let Some(ref description) = self.description { 116 | if !description.is_empty() { 117 | write!(writer, " {}", description)?; 118 | } 119 | } 120 | 121 | if let Some(ref comment) = self.comment { 122 | for comment in comment.split('\n') { 123 | write!(writer, "{}{}; {}", settings.eol, settings.indent, comment)?; 124 | } 125 | } 126 | 127 | for tag in &self.posting_metadata.tags { 128 | write!(writer, "{}{}; {}", settings.eol, settings.indent, tag.name)?; 129 | if let Some(ref value) = tag.value { 130 | write!(writer, ": {}", value)?; 131 | }; 132 | } 133 | 134 | for posting in &self.postings { 135 | write!(writer, "{}{}", settings.eol, settings.indent)?; 136 | posting.write(writer, settings)?; 137 | } 138 | 139 | Ok(()) 140 | } 141 | } 142 | 143 | impl Serializer for TransactionStatus { 144 | fn write(&self, writer: &mut W, _settings: &SerializerSettings) -> Result<(), io::Error> 145 | where 146 | W: io::Write, 147 | { 148 | match self { 149 | TransactionStatus::Pending => write!(writer, "!"), 150 | TransactionStatus::Cleared => write!(writer, "*"), 151 | } 152 | } 153 | } 154 | 155 | impl Serializer for Posting { 156 | fn write(&self, writer: &mut W, settings: &SerializerSettings) -> Result<(), io::Error> 157 | where 158 | W: io::Write, 159 | { 160 | if let Some(ref status) = self.status { 161 | status.write(writer, settings)?; 162 | write!(writer, " ")?; 163 | } 164 | 165 | match self.reality { 166 | Reality::Real => write!(writer, "{}", self.account)?, 167 | Reality::BalancedVirtual => write!(writer, "[{}]", self.account)?, 168 | Reality::UnbalancedVirtual => write!(writer, "({})", self.account)?, 169 | } 170 | 171 | if self.amount.is_some() || self.balance.is_some() { 172 | write!(writer, "{}", settings.indent)?; 173 | } 174 | 175 | if let Some(ref amount) = self.amount { 176 | amount.write(writer, settings)?; 177 | } 178 | 179 | if let Some(ref balance) = self.balance { 180 | write!(writer, " = ")?; 181 | balance.write(writer, settings)?; 182 | } 183 | 184 | for tag in &self.metadata.tags { 185 | write!(writer, "{}; {}", settings.indent, tag.name)?; 186 | if let Some(ref value) = tag.value { 187 | write!(writer, ": {}", value)?; 188 | }; 189 | } 190 | 191 | if let Some(ref comment) = self.comment { 192 | if !comment.contains('\n') && settings.posting_comments_sameline { 193 | write!(writer, "{}; {}", settings.indent, comment)?; 194 | } else { 195 | for comment in comment.split('\n') { 196 | write!(writer, "{}{}; {}", settings.eol, settings.indent, comment)?; 197 | } 198 | } 199 | } 200 | 201 | Ok(()) 202 | } 203 | } 204 | 205 | impl Serializer for PostingAmount { 206 | fn write(&self, writer: &mut W, settings: &SerializerSettings) -> Result<(), io::Error> 207 | where 208 | W: io::Write, 209 | { 210 | self.amount.write(writer, settings)?; 211 | 212 | if let Some(ref lot_price) = self.lot_price { 213 | match lot_price { 214 | Price::Unit(amount) => { 215 | write!(writer, " {{")?; 216 | amount.write(writer, settings)?; 217 | write!(writer, "}}")?; 218 | } 219 | Price::Total(amount) => { 220 | write!(writer, " {{{{")?; 221 | amount.write(writer, settings)?; 222 | write!(writer, "}}}}")?; 223 | } 224 | } 225 | } 226 | 227 | if let Some(ref lot_price) = self.price { 228 | match lot_price { 229 | Price::Unit(amount) => { 230 | write!(writer, " @ ")?; 231 | amount.write(writer, settings)?; 232 | } 233 | Price::Total(amount) => { 234 | write!(writer, " @@ ")?; 235 | amount.write(writer, settings)?; 236 | } 237 | } 238 | } 239 | 240 | Ok(()) 241 | } 242 | } 243 | 244 | impl Serializer for Amount { 245 | fn write(&self, writer: &mut W, _settings: &SerializerSettings) -> Result<(), io::Error> 246 | where 247 | W: io::Write, 248 | { 249 | match self.commodity.position { 250 | CommodityPosition::Left => write!(writer, "{}{}", self.commodity.name, self.quantity), 251 | CommodityPosition::Right => write!(writer, "{} {}", self.quantity, self.commodity.name), 252 | } 253 | } 254 | } 255 | 256 | impl Serializer for Balance { 257 | fn write(&self, writer: &mut W, settings: &SerializerSettings) -> Result<(), io::Error> 258 | where 259 | W: io::Write, 260 | { 261 | match self { 262 | Balance::Zero => write!(writer, "0"), 263 | Balance::Amount(ref balance) => balance.write(writer, settings), 264 | } 265 | } 266 | } 267 | 268 | impl Serializer for CommodityPrice { 269 | fn write(&self, writer: &mut W, settings: &SerializerSettings) -> Result<(), io::Error> 270 | where 271 | W: io::Write, 272 | { 273 | write!( 274 | writer, 275 | "P {} {} ", 276 | self.datetime.format(&settings.commodity_date_format), 277 | self.commodity_name 278 | )?; 279 | self.amount.write(writer, settings)?; 280 | Ok(()) 281 | } 282 | } 283 | 284 | #[cfg(test)] 285 | mod tests { 286 | use super::*; 287 | 288 | #[test] 289 | fn serialize_transaction() { 290 | let ledger = crate::parse( 291 | r#"2018/10/01 (123) Payee 123 292 | TEST:ABC 123 $1.20 293 | TEST:DEF 123"#, 294 | ) 295 | .expect("parsing test transaction"); 296 | 297 | let mut buf = Vec::new(); 298 | ledger 299 | .write(&mut buf, &SerializerSettings::default()) 300 | .expect("serializing test transaction"); 301 | 302 | assert_eq!( 303 | String::from_utf8(buf).unwrap(), 304 | r#"2018-10-01 (123) Payee 123 305 | TEST:ABC 123 $1.20 306 | TEST:DEF 123 307 | "# 308 | ); 309 | } 310 | 311 | #[test] 312 | fn serialize_with_custom_date_format() { 313 | let ledger = crate::parse( 314 | r#"2018-10-01 (123) Payee 123 315 | TEST:ABC 123 $1.20 316 | TEST:DEF 123"#, 317 | ) 318 | .expect("parsing test transaction"); 319 | 320 | let mut buf = Vec::new(); 321 | ledger 322 | .write( 323 | &mut buf, 324 | &SerializerSettings { 325 | transaction_date_format: "%Y/%m/%d".to_owned(), 326 | ..SerializerSettings::default() 327 | }, 328 | ) 329 | .expect("serializing test transaction"); 330 | 331 | assert_eq!( 332 | String::from_utf8(buf).unwrap(), 333 | r#"2018/10/01 (123) Payee 123 334 | TEST:ABC 123 $1.20 335 | TEST:DEF 123 336 | "# 337 | ); 338 | } 339 | 340 | #[test] 341 | fn serialize_tags() { 342 | let ledger = crate::parse( 343 | r#"2018-10-01 (123) Payee 123 344 | ; Tag1: Foo bar 345 | TEST:ABC 123 $1.20 ; Tag2: Fizz bazz 346 | TEST:DEF 123"#, 347 | ) 348 | .expect("parsing test transaction"); 349 | 350 | let mut buf = Vec::new(); 351 | ledger 352 | .write(&mut buf, &SerializerSettings::default()) 353 | .expect("serializing test transaction"); 354 | 355 | assert_eq!( 356 | String::from_utf8(buf).unwrap(), 357 | r#"2018-10-01 (123) Payee 123 358 | ; Tag1: Foo bar 359 | TEST:ABC 123 $1.20 ; Tag2: Fizz bazz 360 | TEST:DEF 123 361 | "# 362 | ); 363 | } 364 | 365 | #[test] 366 | fn serialize_posting_comments_sameline() { 367 | let ledger = crate::parse( 368 | r#"2018-10-01 Payee 123 369 | TEST:ABC 123 $1.20 370 | ; This is a one-line comment 371 | TEST:DEF 123 372 | ; This is a two- 373 | ; line comment"#, 374 | ) 375 | .expect("parsing test transaction"); 376 | 377 | let mut buf = Vec::new(); 378 | ledger 379 | .write( 380 | &mut buf, 381 | &SerializerSettings { 382 | posting_comments_sameline: true, 383 | ..SerializerSettings::default() 384 | }, 385 | ) 386 | .expect("serializing test transaction"); 387 | 388 | assert_eq!( 389 | String::from_utf8(buf).unwrap(), 390 | r#"2018-10-01 Payee 123 391 | TEST:ABC 123 $1.20 ; This is a one-line comment 392 | TEST:DEF 123 393 | ; This is a two- 394 | ; line comment 395 | "# 396 | ); 397 | } 398 | } 399 | --------------------------------------------------------------------------------