rustledger_plugin/native/plugins/
forecast.rs1use regex::Regex;
23use rustledger_core::NaiveDate;
24use std::sync::LazyLock;
25
26use crate::types::{DirectiveData, PluginInput, PluginOp, PluginOutput};
27
28use super::super::{NativePlugin, RegularPlugin};
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 today = jiff::Zoned::now().date();
70 let default_until = rustledger_core::naive_date(i32::from(today.year()), 12, 31).unwrap();
71
72 let mut ops: Vec<PluginOp> = Vec::with_capacity(input.directives.len());
73
74 for (i, directive) in input.directives.into_iter().enumerate() {
75 let is_forecast = matches!(&directive.data,
78 DirectiveData::Transaction(t) if t.flag == "#");
79 if !is_forecast {
80 ops.push(PluginOp::Keep(i));
81 continue;
82 }
83
84 if let DirectiveData::Transaction(ref txn) = directive.data {
85 if let Some(caps) = FORECAST_PATTERN_RE.captures(&txn.narration) {
86 let narration_prefix = caps.get(1).map_or("", |m| m.as_str().trim());
87 let interval_str = caps.get(2).map_or("MONTHLY", |m| m.as_str());
88 let skip_count: usize = caps
89 .get(3)
90 .and_then(|m| m.as_str().parse().ok())
91 .unwrap_or(0);
92 let repeat_count: Option<usize> =
93 caps.get(4).and_then(|m| m.as_str().parse().ok());
94 let until_date: Option<NaiveDate> = caps
95 .get(5)
96 .and_then(|m| m.as_str().parse::<NaiveDate>().ok());
97
98 let interval = match interval_str {
99 "DAILY" => Interval::Daily,
100 "WEEKLY" => Interval::Weekly,
101 "YEARLY" => Interval::Yearly,
102 _ => Interval::Monthly,
103 };
104
105 let start_date = if let Ok(date) = directive.date.parse::<NaiveDate>() {
107 date
108 } else {
109 ops.push(PluginOp::Keep(i));
111 continue;
112 };
113
114 let until = until_date.unwrap_or(default_until);
116
117 let dates =
119 generate_dates(start_date, interval, skip_count, repeat_count, until);
120
121 ops.push(PluginOp::Delete(i));
123 for date in dates {
124 let mut new_directive = directive.clone();
125 new_directive.date = date.to_string();
126 new_directive.filename = None;
127 new_directive.lineno = None;
128
129 if let DirectiveData::Transaction(ref mut new_txn) = new_directive.data {
130 new_txn.narration = narration_prefix.to_string();
131 }
132
133 ops.push(PluginOp::Insert(new_directive));
134 }
135 } else {
136 ops.push(PluginOp::Keep(i));
138 }
139 } else {
140 ops.push(PluginOp::Keep(i));
141 }
142 }
143
144 PluginOutput {
145 ops,
146 errors: Vec::new(),
147 }
148 }
149}
150
151impl RegularPlugin for ForecastPlugin {}
152
153fn generate_dates(
155 start: NaiveDate,
156 interval: Interval,
157 skip: usize,
158 repeat: Option<usize>,
159 until: NaiveDate,
160) -> Vec<NaiveDate> {
161 let mut dates = Vec::new();
162 let mut current = start;
163 let step = skip + 1; loop {
166 dates.push(current);
167
168 if let Some(max_count) = repeat
170 && dates.len() >= max_count
171 {
172 break;
173 }
174
175 current = match interval {
177 Interval::Daily => current
178 .checked_add(jiff::ToSpan::days(step as i64))
179 .unwrap_or(current),
180 Interval::Weekly => current
181 .checked_add(jiff::ToSpan::weeks(step as i64))
182 .unwrap_or(current),
183 Interval::Monthly => current
184 .checked_add(jiff::ToSpan::months(step as i64))
185 .unwrap_or(current),
186 Interval::Yearly => current
187 .checked_add(jiff::ToSpan::years(step as i64))
188 .unwrap_or(current),
189 };
190
191 if current > until {
193 break;
194 }
195
196 if dates.len() > 1000 {
198 break;
199 }
200 }
201
202 dates
203}
204
205#[cfg(test)]
206fn add_months(date: NaiveDate, months: i32) -> NaiveDate {
208 date.checked_add(jiff::ToSpan::months(i64::from(months)))
209 .unwrap_or(date)
210}
211
212#[cfg(test)]
213mod tests {
214 use super::super::utils::materialize_ops;
215 use super::*;
216 use crate::types::*;
217
218 fn create_forecast_transaction(date: &str, narration: &str) -> DirectiveWrapper {
219 DirectiveWrapper {
220 directive_type: "transaction".to_string(),
221 date: date.to_string(),
222 filename: None,
223 lineno: None,
224 data: DirectiveData::Transaction(TransactionData {
225 flag: "#".to_string(),
226 payee: None,
227 narration: narration.to_string(),
228 tags: vec![],
229 links: vec![],
230 metadata: vec![],
231 postings: vec![
232 PostingData {
233 account: "Expenses:Test".to_string(),
234 units: Some(AmountData {
235 number: "100.00".to_string(),
236 currency: "USD".to_string(),
237 }),
238 cost: None,
239 price: None,
240 flag: None,
241 metadata: vec![],
242 span: None,
243 },
244 PostingData {
245 account: "Assets:Cash".to_string(),
246 units: Some(AmountData {
247 number: "-100.00".to_string(),
248 currency: "USD".to_string(),
249 }),
250 cost: None,
251 price: None,
252 flag: None,
253 metadata: vec![],
254 span: None,
255 },
256 ],
257 }),
258 }
259 }
260
261 #[test]
262 fn test_forecast_monthly_repeat() {
263 let plugin = ForecastPlugin;
264
265 let input = PluginInput {
266 directives: vec![create_forecast_transaction(
267 "2024-01-15",
268 "Electric bill [MONTHLY REPEAT 3 TIMES]",
269 )],
270 options: PluginOptions {
271 operating_currencies: vec!["USD".to_string()],
272 title: None,
273 },
274 config: None,
275 };
276
277 let input_dirs = input.directives.clone();
278 let output = plugin.process(input);
279 assert_eq!(output.errors.len(), 0);
280 let directives = materialize_ops(&input_dirs, &output);
281 assert_eq!(directives.len(), 3);
282
283 assert_eq!(directives[0].date, "2024-01-15");
285 assert_eq!(directives[1].date, "2024-02-15");
286 assert_eq!(directives[2].date, "2024-03-15");
287
288 if let DirectiveData::Transaction(txn) = &directives[0].data {
290 assert_eq!(txn.narration, "Electric bill");
291 }
292 }
293
294 #[test]
295 fn test_forecast_weekly_repeat() {
296 let plugin = ForecastPlugin;
297
298 let input = PluginInput {
299 directives: vec![create_forecast_transaction(
300 "2024-01-01",
301 "Groceries [WEEKLY REPEAT 4 TIMES]",
302 )],
303 options: PluginOptions {
304 operating_currencies: vec!["USD".to_string()],
305 title: None,
306 },
307 config: None,
308 };
309
310 let input_dirs = input.directives.clone();
311 let output = plugin.process(input);
312 let directives = materialize_ops(&input_dirs, &output);
313 assert_eq!(directives.len(), 4);
314
315 assert_eq!(directives[0].date, "2024-01-01");
316 assert_eq!(directives[1].date, "2024-01-08");
317 assert_eq!(directives[2].date, "2024-01-15");
318 assert_eq!(directives[3].date, "2024-01-22");
319 }
320
321 #[test]
322 fn test_forecast_until_date() {
323 let plugin = ForecastPlugin;
324
325 let input = PluginInput {
326 directives: vec![create_forecast_transaction(
327 "2024-01-15",
328 "Rent [MONTHLY UNTIL 2024-03-15]",
329 )],
330 options: PluginOptions {
331 operating_currencies: vec!["USD".to_string()],
332 title: None,
333 },
334 config: None,
335 };
336
337 let input_dirs = input.directives.clone();
338 let output = plugin.process(input);
339 let directives = materialize_ops(&input_dirs, &output);
340 assert_eq!(directives.len(), 3);
341
342 assert_eq!(directives[0].date, "2024-01-15");
343 assert_eq!(directives[1].date, "2024-02-15");
344 assert_eq!(directives[2].date, "2024-03-15");
345 }
346
347 #[test]
348 fn test_forecast_skip() {
349 let plugin = ForecastPlugin;
350
351 let input = PluginInput {
352 directives: vec![create_forecast_transaction(
353 "2024-01-01",
354 "Insurance [MONTHLY SKIP 1 TIME REPEAT 3 TIMES]",
355 )],
356 options: PluginOptions {
357 operating_currencies: vec!["USD".to_string()],
358 title: None,
359 },
360 config: None,
361 };
362
363 let input_dirs = input.directives.clone();
364 let output = plugin.process(input);
365 let directives = materialize_ops(&input_dirs, &output);
366 assert_eq!(directives.len(), 3);
367
368 assert_eq!(directives[0].date, "2024-01-01");
370 assert_eq!(directives[1].date, "2024-03-01");
371 assert_eq!(directives[2].date, "2024-05-01");
372 }
373
374 #[test]
375 fn test_forecast_preserves_non_forecast_transactions() {
376 let plugin = ForecastPlugin;
377
378 let mut regular_txn = create_forecast_transaction("2024-01-15", "Regular purchase");
379 if let DirectiveData::Transaction(ref mut txn) = regular_txn.data {
380 txn.flag = "*".to_string(); }
382
383 let input = PluginInput {
384 directives: vec![regular_txn],
385 options: PluginOptions {
386 operating_currencies: vec!["USD".to_string()],
387 title: None,
388 },
389 config: None,
390 };
391
392 let input_dirs = input.directives.clone();
393 let output = plugin.process(input);
394 let directives = materialize_ops(&input_dirs, &output);
395 assert_eq!(directives.len(), 1);
396
397 if let DirectiveData::Transaction(txn) = &directives[0].data {
398 assert_eq!(txn.flag, "*");
399 assert_eq!(txn.narration, "Regular purchase");
400 }
401 }
402
403 #[test]
404 fn test_add_months() {
405 assert_eq!(
407 add_months(rustledger_core::naive_date(2024, 1, 15).unwrap(), 1),
408 rustledger_core::naive_date(2024, 2, 15).unwrap()
409 );
410
411 assert_eq!(
413 add_months(rustledger_core::naive_date(2024, 1, 31).unwrap(), 1),
414 rustledger_core::naive_date(2024, 2, 29).unwrap() );
416
417 assert_eq!(
419 add_months(rustledger_core::naive_date(2024, 11, 15).unwrap(), 3),
420 rustledger_core::naive_date(2025, 2, 15).unwrap()
421 );
422 }
423}