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;
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
151fn generate_dates(
153 start: NaiveDate,
154 interval: Interval,
155 skip: usize,
156 repeat: Option<usize>,
157 until: NaiveDate,
158) -> Vec<NaiveDate> {
159 let mut dates = Vec::new();
160 let mut current = start;
161 let step = skip + 1; loop {
164 dates.push(current);
165
166 if let Some(max_count) = repeat
168 && dates.len() >= max_count
169 {
170 break;
171 }
172
173 current = match interval {
175 Interval::Daily => current
176 .checked_add(jiff::ToSpan::days(step as i64))
177 .unwrap_or(current),
178 Interval::Weekly => current
179 .checked_add(jiff::ToSpan::weeks(step as i64))
180 .unwrap_or(current),
181 Interval::Monthly => current
182 .checked_add(jiff::ToSpan::months(step as i64))
183 .unwrap_or(current),
184 Interval::Yearly => current
185 .checked_add(jiff::ToSpan::years(step as i64))
186 .unwrap_or(current),
187 };
188
189 if current > until {
191 break;
192 }
193
194 if dates.len() > 1000 {
196 break;
197 }
198 }
199
200 dates
201}
202
203#[cfg(test)]
204fn add_months(date: NaiveDate, months: i32) -> NaiveDate {
206 date.checked_add(jiff::ToSpan::months(i64::from(months)))
207 .unwrap_or(date)
208}
209
210#[cfg(test)]
211mod tests {
212 use super::super::utils::materialize_ops;
213 use super::*;
214 use crate::types::*;
215
216 fn create_forecast_transaction(date: &str, narration: &str) -> DirectiveWrapper {
217 DirectiveWrapper {
218 directive_type: "transaction".to_string(),
219 date: date.to_string(),
220 filename: None,
221 lineno: None,
222 data: DirectiveData::Transaction(TransactionData {
223 flag: "#".to_string(),
224 payee: None,
225 narration: narration.to_string(),
226 tags: vec![],
227 links: vec![],
228 metadata: vec![],
229 postings: vec![
230 PostingData {
231 account: "Expenses:Test".to_string(),
232 units: Some(AmountData {
233 number: "100.00".to_string(),
234 currency: "USD".to_string(),
235 }),
236 cost: None,
237 price: None,
238 flag: None,
239 metadata: vec![],
240 },
241 PostingData {
242 account: "Assets:Cash".to_string(),
243 units: Some(AmountData {
244 number: "-100.00".to_string(),
245 currency: "USD".to_string(),
246 }),
247 cost: None,
248 price: None,
249 flag: None,
250 metadata: vec![],
251 },
252 ],
253 }),
254 }
255 }
256
257 #[test]
258 fn test_forecast_monthly_repeat() {
259 let plugin = ForecastPlugin;
260
261 let input = PluginInput {
262 directives: vec![create_forecast_transaction(
263 "2024-01-15",
264 "Electric bill [MONTHLY REPEAT 3 TIMES]",
265 )],
266 options: PluginOptions {
267 operating_currencies: vec!["USD".to_string()],
268 title: None,
269 },
270 config: None,
271 };
272
273 let input_dirs = input.directives.clone();
274 let output = plugin.process(input);
275 assert_eq!(output.errors.len(), 0);
276 let directives = materialize_ops(&input_dirs, &output);
277 assert_eq!(directives.len(), 3);
278
279 assert_eq!(directives[0].date, "2024-01-15");
281 assert_eq!(directives[1].date, "2024-02-15");
282 assert_eq!(directives[2].date, "2024-03-15");
283
284 if let DirectiveData::Transaction(txn) = &directives[0].data {
286 assert_eq!(txn.narration, "Electric bill");
287 }
288 }
289
290 #[test]
291 fn test_forecast_weekly_repeat() {
292 let plugin = ForecastPlugin;
293
294 let input = PluginInput {
295 directives: vec![create_forecast_transaction(
296 "2024-01-01",
297 "Groceries [WEEKLY REPEAT 4 TIMES]",
298 )],
299 options: PluginOptions {
300 operating_currencies: vec!["USD".to_string()],
301 title: None,
302 },
303 config: None,
304 };
305
306 let input_dirs = input.directives.clone();
307 let output = plugin.process(input);
308 let directives = materialize_ops(&input_dirs, &output);
309 assert_eq!(directives.len(), 4);
310
311 assert_eq!(directives[0].date, "2024-01-01");
312 assert_eq!(directives[1].date, "2024-01-08");
313 assert_eq!(directives[2].date, "2024-01-15");
314 assert_eq!(directives[3].date, "2024-01-22");
315 }
316
317 #[test]
318 fn test_forecast_until_date() {
319 let plugin = ForecastPlugin;
320
321 let input = PluginInput {
322 directives: vec![create_forecast_transaction(
323 "2024-01-15",
324 "Rent [MONTHLY UNTIL 2024-03-15]",
325 )],
326 options: PluginOptions {
327 operating_currencies: vec!["USD".to_string()],
328 title: None,
329 },
330 config: None,
331 };
332
333 let input_dirs = input.directives.clone();
334 let output = plugin.process(input);
335 let directives = materialize_ops(&input_dirs, &output);
336 assert_eq!(directives.len(), 3);
337
338 assert_eq!(directives[0].date, "2024-01-15");
339 assert_eq!(directives[1].date, "2024-02-15");
340 assert_eq!(directives[2].date, "2024-03-15");
341 }
342
343 #[test]
344 fn test_forecast_skip() {
345 let plugin = ForecastPlugin;
346
347 let input = PluginInput {
348 directives: vec![create_forecast_transaction(
349 "2024-01-01",
350 "Insurance [MONTHLY SKIP 1 TIME REPEAT 3 TIMES]",
351 )],
352 options: PluginOptions {
353 operating_currencies: vec!["USD".to_string()],
354 title: None,
355 },
356 config: None,
357 };
358
359 let input_dirs = input.directives.clone();
360 let output = plugin.process(input);
361 let directives = materialize_ops(&input_dirs, &output);
362 assert_eq!(directives.len(), 3);
363
364 assert_eq!(directives[0].date, "2024-01-01");
366 assert_eq!(directives[1].date, "2024-03-01");
367 assert_eq!(directives[2].date, "2024-05-01");
368 }
369
370 #[test]
371 fn test_forecast_preserves_non_forecast_transactions() {
372 let plugin = ForecastPlugin;
373
374 let mut regular_txn = create_forecast_transaction("2024-01-15", "Regular purchase");
375 if let DirectiveData::Transaction(ref mut txn) = regular_txn.data {
376 txn.flag = "*".to_string(); }
378
379 let input = PluginInput {
380 directives: vec![regular_txn],
381 options: PluginOptions {
382 operating_currencies: vec!["USD".to_string()],
383 title: None,
384 },
385 config: None,
386 };
387
388 let input_dirs = input.directives.clone();
389 let output = plugin.process(input);
390 let directives = materialize_ops(&input_dirs, &output);
391 assert_eq!(directives.len(), 1);
392
393 if let DirectiveData::Transaction(txn) = &directives[0].data {
394 assert_eq!(txn.flag, "*");
395 assert_eq!(txn.narration, "Regular purchase");
396 }
397 }
398
399 #[test]
400 fn test_add_months() {
401 assert_eq!(
403 add_months(rustledger_core::naive_date(2024, 1, 15).unwrap(), 1),
404 rustledger_core::naive_date(2024, 2, 15).unwrap()
405 );
406
407 assert_eq!(
409 add_months(rustledger_core::naive_date(2024, 1, 31).unwrap(), 1),
410 rustledger_core::naive_date(2024, 2, 29).unwrap() );
412
413 assert_eq!(
415 add_months(rustledger_core::naive_date(2024, 11, 15).unwrap(), 3),
416 rustledger_core::naive_date(2025, 2, 15).unwrap()
417 );
418 }
419}