rustledger_plugin/native/plugins/
forecast.rs1use regex::Regex;
23use rustledger_core::NaiveDate;
24use std::sync::LazyLock;
25
26use crate::types::{DirectiveData, PluginInput, PluginOutput};
27
28use super::super::NativePlugin;
29
30static FORECAST_PATTERN_RE: LazyLock<Regex> = LazyLock::new(|| {
33 Regex::new(
34 r"(?x)
35 (^.*?) # narration prefix
36 \[
37 (MONTHLY|YEARLY|WEEKLY|DAILY) # interval type
38 (?:\s+SKIP\s+(\d+)\s+TIMES?)? # optional SKIP n TIMES
39 (?:\s+REPEAT\s+(\d+)\s+TIMES?)? # optional REPEAT n TIMES
40 (?:\s+UNTIL\s+(\d{4}-\d{2}-\d{2}))? # optional UNTIL date
41 \]
42 ",
43 )
44 .expect("FORECAST_PATTERN_RE: invalid regex pattern")
45});
46
47pub struct ForecastPlugin;
49
50#[derive(Debug, Clone, Copy, PartialEq)]
51enum Interval {
52 Daily,
53 Weekly,
54 Monthly,
55 Yearly,
56}
57
58impl NativePlugin for ForecastPlugin {
59 fn name(&self) -> &'static str {
60 "forecast"
61 }
62
63 fn description(&self) -> &'static str {
64 "Generate recurring forecast transactions"
65 }
66
67 fn process(&self, input: PluginInput) -> PluginOutput {
68 let mut forecast_entries = Vec::new();
69 let mut filtered_entries = Vec::new();
70
71 for directive in input.directives {
73 if directive.directive_type == "transaction"
74 && let DirectiveData::Transaction(ref txn) = directive.data
75 && txn.flag == "#"
76 {
77 forecast_entries.push(directive);
78 } else {
79 filtered_entries.push(directive);
80 }
81 }
82
83 let today = jiff::Zoned::now().date();
85 let default_until = rustledger_core::naive_date(i32::from(today.year()), 12, 31).unwrap();
86
87 let mut new_entries = Vec::new();
89
90 for directive in forecast_entries {
91 if let DirectiveData::Transaction(ref txn) = directive.data {
92 if let Some(caps) = FORECAST_PATTERN_RE.captures(&txn.narration) {
93 let narration_prefix = caps.get(1).map_or("", |m| m.as_str().trim());
94 let interval_str = caps.get(2).map_or("MONTHLY", |m| m.as_str());
95 let skip_count: usize = caps
96 .get(3)
97 .and_then(|m| m.as_str().parse().ok())
98 .unwrap_or(0);
99 let repeat_count: Option<usize> =
100 caps.get(4).and_then(|m| m.as_str().parse().ok());
101 let until_date: Option<NaiveDate> = caps
102 .get(5)
103 .and_then(|m| m.as_str().parse::<NaiveDate>().ok());
104
105 let interval = match interval_str {
106 "DAILY" => Interval::Daily,
107 "WEEKLY" => Interval::Weekly,
108 "YEARLY" => Interval::Yearly,
109 _ => Interval::Monthly,
110 };
111
112 let start_date = if let Ok(date) = directive.date.parse::<NaiveDate>() {
114 date
115 } else {
116 new_entries.push(directive);
118 continue;
119 };
120
121 let until = until_date.unwrap_or(default_until);
123
124 let dates =
126 generate_dates(start_date, interval, skip_count, repeat_count, until);
127
128 for date in dates {
130 let mut new_directive = directive.clone();
131 new_directive.date = date.to_string();
132
133 if let DirectiveData::Transaction(ref mut new_txn) = new_directive.data {
134 new_txn.narration = narration_prefix.to_string();
135 }
136
137 new_entries.push(new_directive);
138 }
139 } else {
140 new_entries.push(directive);
142 }
143 }
144 }
145
146 new_entries.sort_by(|a, b| a.date.cmp(&b.date));
148
149 filtered_entries.extend(new_entries);
151
152 PluginOutput {
153 directives: filtered_entries,
154 errors: Vec::new(),
155 }
156 }
157}
158
159fn generate_dates(
161 start: NaiveDate,
162 interval: Interval,
163 skip: usize,
164 repeat: Option<usize>,
165 until: NaiveDate,
166) -> Vec<NaiveDate> {
167 let mut dates = Vec::new();
168 let mut current = start;
169 let step = skip + 1; loop {
172 dates.push(current);
173
174 if let Some(max_count) = repeat
176 && dates.len() >= max_count
177 {
178 break;
179 }
180
181 current = match interval {
183 Interval::Daily => current
184 .checked_add(jiff::ToSpan::days(step as i64))
185 .unwrap_or(current),
186 Interval::Weekly => current
187 .checked_add(jiff::ToSpan::weeks(step as i64))
188 .unwrap_or(current),
189 Interval::Monthly => current
190 .checked_add(jiff::ToSpan::months(step as i64))
191 .unwrap_or(current),
192 Interval::Yearly => current
193 .checked_add(jiff::ToSpan::years(step as i64))
194 .unwrap_or(current),
195 };
196
197 if current > until {
199 break;
200 }
201
202 if dates.len() > 1000 {
204 break;
205 }
206 }
207
208 dates
209}
210
211#[cfg(test)]
212fn add_months(date: NaiveDate, months: i32) -> NaiveDate {
214 date.checked_add(jiff::ToSpan::months(i64::from(months)))
215 .unwrap_or(date)
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use crate::types::*;
222
223 fn create_forecast_transaction(date: &str, narration: &str) -> DirectiveWrapper {
224 DirectiveWrapper {
225 directive_type: "transaction".to_string(),
226 date: date.to_string(),
227 filename: None,
228 lineno: None,
229 data: DirectiveData::Transaction(TransactionData {
230 flag: "#".to_string(),
231 payee: None,
232 narration: narration.to_string(),
233 tags: vec![],
234 links: vec![],
235 metadata: vec![],
236 postings: vec![
237 PostingData {
238 account: "Expenses:Test".to_string(),
239 units: Some(AmountData {
240 number: "100.00".to_string(),
241 currency: "USD".to_string(),
242 }),
243 cost: None,
244 price: None,
245 flag: None,
246 metadata: vec![],
247 },
248 PostingData {
249 account: "Assets:Cash".to_string(),
250 units: Some(AmountData {
251 number: "-100.00".to_string(),
252 currency: "USD".to_string(),
253 }),
254 cost: None,
255 price: None,
256 flag: None,
257 metadata: vec![],
258 },
259 ],
260 }),
261 }
262 }
263
264 #[test]
265 fn test_forecast_monthly_repeat() {
266 let plugin = ForecastPlugin;
267
268 let input = PluginInput {
269 directives: vec![create_forecast_transaction(
270 "2024-01-15",
271 "Electric bill [MONTHLY REPEAT 3 TIMES]",
272 )],
273 options: PluginOptions {
274 operating_currencies: vec!["USD".to_string()],
275 title: None,
276 },
277 config: None,
278 };
279
280 let output = plugin.process(input);
281 assert_eq!(output.errors.len(), 0);
282 assert_eq!(output.directives.len(), 3);
283
284 assert_eq!(output.directives[0].date, "2024-01-15");
286 assert_eq!(output.directives[1].date, "2024-02-15");
287 assert_eq!(output.directives[2].date, "2024-03-15");
288
289 if let DirectiveData::Transaction(txn) = &output.directives[0].data {
291 assert_eq!(txn.narration, "Electric bill");
292 }
293 }
294
295 #[test]
296 fn test_forecast_weekly_repeat() {
297 let plugin = ForecastPlugin;
298
299 let input = PluginInput {
300 directives: vec![create_forecast_transaction(
301 "2024-01-01",
302 "Groceries [WEEKLY REPEAT 4 TIMES]",
303 )],
304 options: PluginOptions {
305 operating_currencies: vec!["USD".to_string()],
306 title: None,
307 },
308 config: None,
309 };
310
311 let output = plugin.process(input);
312 assert_eq!(output.directives.len(), 4);
313
314 assert_eq!(output.directives[0].date, "2024-01-01");
315 assert_eq!(output.directives[1].date, "2024-01-08");
316 assert_eq!(output.directives[2].date, "2024-01-15");
317 assert_eq!(output.directives[3].date, "2024-01-22");
318 }
319
320 #[test]
321 fn test_forecast_until_date() {
322 let plugin = ForecastPlugin;
323
324 let input = PluginInput {
325 directives: vec![create_forecast_transaction(
326 "2024-01-15",
327 "Rent [MONTHLY UNTIL 2024-03-15]",
328 )],
329 options: PluginOptions {
330 operating_currencies: vec!["USD".to_string()],
331 title: None,
332 },
333 config: None,
334 };
335
336 let output = plugin.process(input);
337 assert_eq!(output.directives.len(), 3);
338
339 assert_eq!(output.directives[0].date, "2024-01-15");
340 assert_eq!(output.directives[1].date, "2024-02-15");
341 assert_eq!(output.directives[2].date, "2024-03-15");
342 }
343
344 #[test]
345 fn test_forecast_skip() {
346 let plugin = ForecastPlugin;
347
348 let input = PluginInput {
349 directives: vec![create_forecast_transaction(
350 "2024-01-01",
351 "Insurance [MONTHLY SKIP 1 TIME REPEAT 3 TIMES]",
352 )],
353 options: PluginOptions {
354 operating_currencies: vec!["USD".to_string()],
355 title: None,
356 },
357 config: None,
358 };
359
360 let output = plugin.process(input);
361 assert_eq!(output.directives.len(), 3);
362
363 assert_eq!(output.directives[0].date, "2024-01-01");
365 assert_eq!(output.directives[1].date, "2024-03-01");
366 assert_eq!(output.directives[2].date, "2024-05-01");
367 }
368
369 #[test]
370 fn test_forecast_preserves_non_forecast_transactions() {
371 let plugin = ForecastPlugin;
372
373 let mut regular_txn = create_forecast_transaction("2024-01-15", "Regular purchase");
374 if let DirectiveData::Transaction(ref mut txn) = regular_txn.data {
375 txn.flag = "*".to_string(); }
377
378 let input = PluginInput {
379 directives: vec![regular_txn],
380 options: PluginOptions {
381 operating_currencies: vec!["USD".to_string()],
382 title: None,
383 },
384 config: None,
385 };
386
387 let output = plugin.process(input);
388 assert_eq!(output.directives.len(), 1);
389
390 if let DirectiveData::Transaction(txn) = &output.directives[0].data {
391 assert_eq!(txn.flag, "*");
392 assert_eq!(txn.narration, "Regular purchase");
393 }
394 }
395
396 #[test]
397 fn test_add_months() {
398 assert_eq!(
400 add_months(rustledger_core::naive_date(2024, 1, 15).unwrap(), 1),
401 rustledger_core::naive_date(2024, 2, 15).unwrap()
402 );
403
404 assert_eq!(
406 add_months(rustledger_core::naive_date(2024, 1, 31).unwrap(), 1),
407 rustledger_core::naive_date(2024, 2, 29).unwrap() );
409
410 assert_eq!(
412 add_months(rustledger_core::naive_date(2024, 11, 15).unwrap(), 3),
413 rustledger_core::naive_date(2025, 2, 15).unwrap()
414 );
415 }
416}