1use std::fs;
2
3use crate::{errors::StatementParseError, parsers::prelude::*, types::Transaction};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub enum ParsedTransaction {
8 Qfx(QfxTransaction),
9}
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12pub enum FileFormat {
13 #[serde(rename = "qfx")]
14 Qfx,
15}
16
17impl FileFormat {
18 fn parse_raw(&self, content: &str) -> Result<Vec<ParsedTransaction>, StatementParseError> {
19 match self {
20 FileFormat::Qfx => {
21 let transactions =
22 QfxParser::parse(content).map_err(StatementParseError::ParseFailed)?;
23 Ok(transactions
24 .into_iter()
25 .map(ParsedTransaction::Qfx)
26 .collect())
27 }
28 }
29 }
30
31 fn parse<T>(&self, content: &str) -> Result<Vec<T>, StatementParseError>
32 where
33 T: TryFrom<ParsedTransaction, Error = StatementParseError>,
34 {
35 self.parse_raw(content)?
36 .into_iter()
37 .map(T::try_from)
38 .collect()
39 }
40
41 fn detect(filename: Option<&str>, content: Option<&str>) -> Result<Self, StatementParseError> {
42 if let Some(content) = content {
43 if QfxParser::is_supported(filename, content) {
44 return Ok(FileFormat::Qfx);
45 }
46 }
47
48 if let Some(filename) = filename {
49 if let Some(ext) = filename.split('.').last() {
50 if matches!(ext, "qfx" | "ofx") {
51 return Ok(FileFormat::Qfx);
52 }
53 }
54 }
55
56 Err(StatementParseError::UnsupportedFormat)
57 }
58}
59
60#[derive(Default)]
61pub struct ParserBuilder {
62 content: Option<String>,
63 filepath: Option<String>,
64 format: Option<FileFormat>,
65}
66
67impl ParserBuilder {
68 pub fn new() -> Self {
69 Self::default()
70 }
71
72 pub fn content(mut self, content: &str) -> Self {
73 self.content = Some(content.to_string());
74 self
75 }
76
77 pub fn filename(mut self, filename: &str) -> Self {
78 self.filepath = Some(filename.to_string());
79 self
80 }
81
82 pub fn format(mut self, format: FileFormat) -> Self {
83 self.format = Some(format);
84 self
85 }
86
87 pub fn parse(self) -> Result<Vec<Transaction>, StatementParseError> {
88 self.parse_into::<Transaction>()
89 }
90
91 pub fn parse_into<T>(self) -> Result<Vec<T>, StatementParseError>
92 where
93 T: TryFrom<ParsedTransaction, Error = StatementParseError>,
94 {
95 let format = self.format.map(Ok).unwrap_or_else(|| {
96 FileFormat::detect(self.filepath.as_deref(), self.content.as_deref())
97 })?;
98
99 let content = self.content.map(Ok).unwrap_or_else(|| {
100 self.filepath
101 .ok_or(StatementParseError::MissingContentAndFilepath)
102 .and_then(|path| fs::read_to_string(path).map_err(Into::into))
103 })?;
104
105 format.parse(&content)
106 }
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use rstest::rstest;
113 use rust_decimal::Decimal;
114 use std::str::FromStr;
115
116 const SAMPLE_QFX: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
117<OFX>
118 <BANKMSGSRSV1>
119 <STMTTRNRS>
120 <STMTRS>
121 <BANKTRANLIST>
122 <STMTTRN>
123 <TRNTYPE>DEBIT</TRNTYPE>
124 <DTPOSTED>20251226120000</DTPOSTED>
125 <TRNAMT>-50.00</TRNAMT>
126 <FITID>202512260</FITID>
127 <NAME>Coffee Shop</NAME>
128 <MEMO>Morning coffee</MEMO>
129 </STMTTRN>
130 </BANKTRANLIST>
131 </STMTRS>
132 </STMTTRNRS>
133 </BANKMSGSRSV1>
134</OFX>"#;
135
136 #[test]
137 fn test_builder_missing_content() {
138 let result: Result<Vec<Transaction>, _> = ParserBuilder::new().parse();
139 assert!(matches!(
140 result,
141 Err(StatementParseError::UnsupportedFormat)
142 ));
143 }
144
145 #[test]
146 fn test_builder_with_format() {
147 let builder = ParserBuilder::new().content("test").format(FileFormat::Qfx);
148
149 assert!(builder.format.is_some());
150 assert_eq!(builder.format.unwrap(), FileFormat::Qfx);
151 }
152
153 #[test]
154 fn test_builder_new() {
155 let builder = ParserBuilder::new();
156 assert!(builder.content.is_none());
157 assert!(builder.filepath.is_none());
158 assert!(builder.format.is_none());
159 }
160
161 #[test]
162 fn test_builder_default() {
163 let builder = ParserBuilder::default();
164 assert!(builder.content.is_none());
165 assert!(builder.filepath.is_none());
166 assert!(builder.format.is_none());
167 }
168
169 #[test]
170 fn test_builder_content() {
171 let builder = ParserBuilder::new().content("test content");
172 assert_eq!(builder.content.unwrap(), "test content");
173 }
174
175 #[test]
176 fn test_builder_filename() {
177 let builder = ParserBuilder::new().filename("test.qfx");
178 assert_eq!(builder.filepath.unwrap(), "test.qfx");
179 }
180
181 #[test]
182 fn test_builder_chaining() {
183 let builder = ParserBuilder::new()
184 .content("content")
185 .filename("file.qfx")
186 .format(FileFormat::Qfx);
187
188 assert!(builder.content.is_some());
189 assert!(builder.filepath.is_some());
190 assert!(builder.format.is_some());
191 }
192
193 #[rstest]
194 #[case(Some(FileFormat::Qfx), None, "Explicit format")]
195 #[case(None, None, "Auto-detect by content")]
196 #[case(None, Some("statement.qfx"), "Auto-detect by filename")]
197 #[case(None, Some("statement.ofx"), "Auto-detect by .ofx extension")]
198 fn test_parse_with_different_detection_methods(
199 #[case] format: Option<FileFormat>,
200 #[case] filename: Option<&str>,
201 #[case] _description: &str,
202 ) {
203 let mut builder = ParserBuilder::new().content(SAMPLE_QFX);
204
205 if let Some(fmt) = format {
206 builder = builder.format(fmt);
207 }
208 if let Some(fname) = filename {
209 builder = builder.filename(fname);
210 }
211
212 let result = builder.parse();
213 assert!(result.is_ok());
214
215 let transactions = result.unwrap();
216 assert_eq!(transactions.len(), 1);
217 assert_eq!(transactions[0].transaction_type, "DEBIT");
218 }
219
220 #[test]
221 fn test_parse_raw_to_qfx_transaction() {
222 let result = FileFormat::Qfx.parse_raw(SAMPLE_QFX);
223
224 assert!(result.is_ok());
225 let parsed = result.unwrap();
226 assert_eq!(parsed.len(), 1);
227
228 match &parsed[0] {
229 ParsedTransaction::Qfx(txn) => {
230 assert_eq!(txn.trn_type, "DEBIT");
231 assert_eq!(txn.amount, Decimal::from_str("-50.00").unwrap());
232 }
233 }
234 }
235
236 #[test]
237 fn test_parse_into_transaction() {
238 let result = ParserBuilder::new()
239 .content(SAMPLE_QFX)
240 .format(FileFormat::Qfx)
241 .parse_into::<Transaction>();
242
243 assert!(result.is_ok());
244 let transactions = result.unwrap();
245 assert_eq!(transactions.len(), 1);
246 assert_eq!(transactions[0].transaction_type, "DEBIT");
247 }
248
249 #[test]
250 fn test_parse_unsupported_format() {
251 let result = ParserBuilder::new()
252 .content("random content that's not OFX")
253 .parse();
254
255 assert!(result.is_err());
256 assert!(matches!(
257 result.unwrap_err(),
258 StatementParseError::UnsupportedFormat
259 ));
260 }
261
262 #[test]
263 fn test_parse_no_content_no_filepath() {
264 let result = ParserBuilder::new().format(FileFormat::Qfx).parse();
265
266 assert!(result.is_err());
267 }
268
269 #[test]
270 fn test_parse_invalid_content() {
271 let result = ParserBuilder::new()
272 .content("invalid QFX content")
273 .format(FileFormat::Qfx)
274 .parse();
275
276 assert!(result.is_err());
277 }
278
279 #[rstest]
280 #[case(None, Some(SAMPLE_QFX), true)] #[case(Some("statement.qfx"), None, true)] #[case(Some("statement.ofx"), None, true)] #[case(Some("statement.QFX"), Some(SAMPLE_QFX), true)] #[case(Some("statement.OFX"), Some(SAMPLE_QFX), true)] #[case(Some("statement.csv"), Some("random content"), false)] #[case(None, None, false)] #[case(Some("statement.txt"), Some("not ofx"), false)] fn test_file_format_detect(
289 #[case] filename: Option<&str>,
290 #[case] content: Option<&str>,
291 #[case] should_succeed: bool,
292 ) {
293 let result = FileFormat::detect(filename, content);
294 if should_succeed {
295 assert!(result.is_ok());
296 assert_eq!(result.unwrap(), FileFormat::Qfx);
297 } else {
298 assert!(result.is_err());
299 assert!(matches!(
300 result.unwrap_err(),
301 StatementParseError::UnsupportedFormat
302 ));
303 }
304 }
305
306 #[test]
307 fn test_file_format_parse_raw() {
308 let result = FileFormat::Qfx.parse_raw(SAMPLE_QFX);
309 assert!(result.is_ok());
310
311 let parsed = result.unwrap();
312 assert_eq!(parsed.len(), 1);
313
314 match &parsed[0] {
315 ParsedTransaction::Qfx(txn) => {
316 assert_eq!(txn.trn_type, "DEBIT");
317 assert_eq!(txn.amount, Decimal::from_str("-50.00").unwrap());
318 }
319 }
320 }
321
322 #[test]
323 fn test_file_format_parse() {
324 let result = FileFormat::Qfx.parse::<Transaction>(SAMPLE_QFX);
325 assert!(result.is_ok());
326
327 let transactions = result.unwrap();
328 assert_eq!(transactions.len(), 1);
329 assert_eq!(transactions[0].transaction_type, "DEBIT");
330 }
331
332 #[test]
333 fn test_parsed_transaction_qfx_variant() {
334 let qfx_txn = QfxTransaction {
335 trn_type: "DEBIT".to_string(),
336 dt_posted: "20251226120000".into(),
337 amount: Decimal::from_str("-50.00").unwrap(),
338 fitid: Some("123".to_string()),
339 name: Some("Test".to_string()),
340 memo: Some("Memo".to_string()),
341 };
342
343 let parsed = ParsedTransaction::Qfx(qfx_txn);
344
345 match parsed {
346 ParsedTransaction::Qfx(txn) => {
347 assert_eq!(txn.trn_type, "DEBIT");
348 assert_eq!(txn.amount, Decimal::from_str("-50.00").unwrap());
349 }
350 }
351 }
352
353 #[test]
354 fn test_parsed_transaction_serialization() {
355 let qfx_txn = QfxTransaction {
356 trn_type: "DEBIT".to_string(),
357 dt_posted: "20251226120000".into(),
358 amount: Decimal::from_str("-50.00").unwrap(),
359 fitid: Some("123".to_string()),
360 name: Some("Test".to_string()),
361 memo: None,
362 };
363
364 let parsed = ParsedTransaction::Qfx(qfx_txn);
365 let json = serde_json::to_string(&parsed).unwrap();
366 assert!(json.contains("DEBIT"));
367
368 let deserialized: ParsedTransaction = serde_json::from_str(&json).unwrap();
369 match deserialized {
370 ParsedTransaction::Qfx(txn) => {
371 assert_eq!(txn.trn_type, "DEBIT");
372 }
373 }
374 }
375
376 #[test]
377 fn test_file_format_serialization() {
378 let format = FileFormat::Qfx;
379 let json = serde_json::to_string(&format).unwrap();
380 assert!(json.contains("qfx"));
381
382 let deserialized: FileFormat = serde_json::from_str(&json).unwrap();
383 assert_eq!(deserialized, FileFormat::Qfx);
384 }
385
386 #[test]
387 fn test_file_format_debug() {
388 let format = FileFormat::Qfx;
389 let debug_str = format!("{:?}", format);
390 assert!(debug_str.contains("Qfx"));
391 }
392
393 #[test]
394 fn test_parsed_transaction_debug() {
395 let qfx_txn = QfxTransaction {
396 trn_type: "DEBIT".to_string(),
397 dt_posted: "20251226120000".into(),
398 amount: Decimal::from_str("-50.00").unwrap(),
399 fitid: None,
400 name: None,
401 memo: None,
402 };
403
404 let parsed = ParsedTransaction::Qfx(qfx_txn);
405 let debug_str = format!("{:?}", parsed);
406 assert!(debug_str.contains("Qfx"));
407 }
408
409 #[test]
410 fn test_parsed_transaction_clone() {
411 let qfx_txn = QfxTransaction {
412 trn_type: "DEBIT".to_string(),
413 dt_posted: "20251226120000".into(),
414 amount: Decimal::from_str("-50.00").unwrap(),
415 fitid: None,
416 name: None,
417 memo: None,
418 };
419
420 let parsed = ParsedTransaction::Qfx(qfx_txn);
421 let cloned = parsed.clone();
422
423 match (parsed, cloned) {
424 (ParsedTransaction::Qfx(a), ParsedTransaction::Qfx(b)) => {
425 assert_eq!(a.trn_type, b.trn_type);
426 assert_eq!(a.amount, b.amount);
427 }
428 }
429 }
430
431 #[test]
432 fn test_builder_parse_invalid_qfx() {
433 let invalid_qfx = r#"<?xml version="1.0" encoding="UTF-8"?>
434<OFX>
435 <BANKMSGSRSV1>
436 <STMTTRNRS>
437 <STMTRS>
438 <BANKTRANLIST>
439 <STMTTRN>
440 <TRNTYPE>DEBIT</TRNTYPE>
441 <DTPOSTED>20251226120000</DTPOSTED>
442 <TRNAMT>invalid</TRNAMT>
443 </STMTTRN>
444 </BANKTRANLIST>
445 </STMTRS>
446 </STMTTRNRS>
447 </BANKMSGSRSV1>
448</OFX>"#;
449
450 let result = ParserBuilder::new()
451 .content(invalid_qfx)
452 .format(FileFormat::Qfx)
453 .parse();
454
455 assert!(result.is_err());
456 }
457}