use regex::Regex;
use rustledger_core::NaiveDate;
use std::sync::LazyLock;
use crate::types::{DirectiveData, PluginInput, PluginOutput};
use super::super::NativePlugin;
static FORECAST_PATTERN_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?x)
(^.*?) # narration prefix
\[
(MONTHLY|YEARLY|WEEKLY|DAILY) # interval type
(?:\s+SKIP\s+(\d+)\s+TIMES?)? # optional SKIP n TIMES
(?:\s+REPEAT\s+(\d+)\s+TIMES?)? # optional REPEAT n TIMES
(?:\s+UNTIL\s+(\d{4}-\d{2}-\d{2}))? # optional UNTIL date
\]
",
)
.expect("FORECAST_PATTERN_RE: invalid regex pattern")
});
pub struct ForecastPlugin;
#[derive(Debug, Clone, Copy, PartialEq)]
enum Interval {
Daily,
Weekly,
Monthly,
Yearly,
}
impl NativePlugin for ForecastPlugin {
fn name(&self) -> &'static str {
"forecast"
}
fn description(&self) -> &'static str {
"Generate recurring forecast transactions"
}
fn process(&self, input: PluginInput) -> PluginOutput {
let mut forecast_entries = Vec::new();
let mut filtered_entries = Vec::new();
for directive in input.directives {
if directive.directive_type == "transaction"
&& let DirectiveData::Transaction(ref txn) = directive.data
&& txn.flag == "#"
{
forecast_entries.push(directive);
} else {
filtered_entries.push(directive);
}
}
let today = jiff::Zoned::now().date();
let default_until = rustledger_core::naive_date(i32::from(today.year()), 12, 31).unwrap();
let mut new_entries = Vec::new();
for directive in forecast_entries {
if let DirectiveData::Transaction(ref txn) = directive.data {
if let Some(caps) = FORECAST_PATTERN_RE.captures(&txn.narration) {
let narration_prefix = caps.get(1).map_or("", |m| m.as_str().trim());
let interval_str = caps.get(2).map_or("MONTHLY", |m| m.as_str());
let skip_count: usize = caps
.get(3)
.and_then(|m| m.as_str().parse().ok())
.unwrap_or(0);
let repeat_count: Option<usize> =
caps.get(4).and_then(|m| m.as_str().parse().ok());
let until_date: Option<NaiveDate> = caps
.get(5)
.and_then(|m| m.as_str().parse::<NaiveDate>().ok());
let interval = match interval_str {
"DAILY" => Interval::Daily,
"WEEKLY" => Interval::Weekly,
"YEARLY" => Interval::Yearly,
_ => Interval::Monthly,
};
let start_date = if let Ok(date) = directive.date.parse::<NaiveDate>() {
date
} else {
new_entries.push(directive);
continue;
};
let until = until_date.unwrap_or(default_until);
let dates =
generate_dates(start_date, interval, skip_count, repeat_count, until);
for date in dates {
let mut new_directive = directive.clone();
new_directive.date = date.to_string();
if let DirectiveData::Transaction(ref mut new_txn) = new_directive.data {
new_txn.narration = narration_prefix.to_string();
}
new_entries.push(new_directive);
}
} else {
new_entries.push(directive);
}
}
}
new_entries.sort_by(|a, b| a.date.cmp(&b.date));
filtered_entries.extend(new_entries);
PluginOutput {
directives: filtered_entries,
errors: Vec::new(),
}
}
}
fn generate_dates(
start: NaiveDate,
interval: Interval,
skip: usize,
repeat: Option<usize>,
until: NaiveDate,
) -> Vec<NaiveDate> {
let mut dates = Vec::new();
let mut current = start;
let step = skip + 1;
loop {
dates.push(current);
if let Some(max_count) = repeat
&& dates.len() >= max_count
{
break;
}
current = match interval {
Interval::Daily => current
.checked_add(jiff::ToSpan::days(step as i64))
.unwrap_or(current),
Interval::Weekly => current
.checked_add(jiff::ToSpan::weeks(step as i64))
.unwrap_or(current),
Interval::Monthly => current
.checked_add(jiff::ToSpan::months(step as i64))
.unwrap_or(current),
Interval::Yearly => current
.checked_add(jiff::ToSpan::years(step as i64))
.unwrap_or(current),
};
if current > until {
break;
}
if dates.len() > 1000 {
break;
}
}
dates
}
#[cfg(test)]
fn add_months(date: NaiveDate, months: i32) -> NaiveDate {
date.checked_add(jiff::ToSpan::months(i64::from(months)))
.unwrap_or(date)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::*;
fn create_forecast_transaction(date: &str, narration: &str) -> DirectiveWrapper {
DirectiveWrapper {
directive_type: "transaction".to_string(),
date: date.to_string(),
filename: None,
lineno: None,
data: DirectiveData::Transaction(TransactionData {
flag: "#".to_string(),
payee: None,
narration: narration.to_string(),
tags: vec![],
links: vec![],
metadata: vec![],
postings: vec![
PostingData {
account: "Expenses:Test".to_string(),
units: Some(AmountData {
number: "100.00".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
},
PostingData {
account: "Assets:Cash".to_string(),
units: Some(AmountData {
number: "-100.00".to_string(),
currency: "USD".to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
},
],
}),
}
}
#[test]
fn test_forecast_monthly_repeat() {
let plugin = ForecastPlugin;
let input = PluginInput {
directives: vec![create_forecast_transaction(
"2024-01-15",
"Electric bill [MONTHLY REPEAT 3 TIMES]",
)],
options: PluginOptions {
operating_currencies: vec!["USD".to_string()],
title: None,
},
config: None,
};
let output = plugin.process(input);
assert_eq!(output.errors.len(), 0);
assert_eq!(output.directives.len(), 3);
assert_eq!(output.directives[0].date, "2024-01-15");
assert_eq!(output.directives[1].date, "2024-02-15");
assert_eq!(output.directives[2].date, "2024-03-15");
if let DirectiveData::Transaction(txn) = &output.directives[0].data {
assert_eq!(txn.narration, "Electric bill");
}
}
#[test]
fn test_forecast_weekly_repeat() {
let plugin = ForecastPlugin;
let input = PluginInput {
directives: vec![create_forecast_transaction(
"2024-01-01",
"Groceries [WEEKLY REPEAT 4 TIMES]",
)],
options: PluginOptions {
operating_currencies: vec!["USD".to_string()],
title: None,
},
config: None,
};
let output = plugin.process(input);
assert_eq!(output.directives.len(), 4);
assert_eq!(output.directives[0].date, "2024-01-01");
assert_eq!(output.directives[1].date, "2024-01-08");
assert_eq!(output.directives[2].date, "2024-01-15");
assert_eq!(output.directives[3].date, "2024-01-22");
}
#[test]
fn test_forecast_until_date() {
let plugin = ForecastPlugin;
let input = PluginInput {
directives: vec![create_forecast_transaction(
"2024-01-15",
"Rent [MONTHLY UNTIL 2024-03-15]",
)],
options: PluginOptions {
operating_currencies: vec!["USD".to_string()],
title: None,
},
config: None,
};
let output = plugin.process(input);
assert_eq!(output.directives.len(), 3);
assert_eq!(output.directives[0].date, "2024-01-15");
assert_eq!(output.directives[1].date, "2024-02-15");
assert_eq!(output.directives[2].date, "2024-03-15");
}
#[test]
fn test_forecast_skip() {
let plugin = ForecastPlugin;
let input = PluginInput {
directives: vec![create_forecast_transaction(
"2024-01-01",
"Insurance [MONTHLY SKIP 1 TIME REPEAT 3 TIMES]",
)],
options: PluginOptions {
operating_currencies: vec!["USD".to_string()],
title: None,
},
config: None,
};
let output = plugin.process(input);
assert_eq!(output.directives.len(), 3);
assert_eq!(output.directives[0].date, "2024-01-01");
assert_eq!(output.directives[1].date, "2024-03-01");
assert_eq!(output.directives[2].date, "2024-05-01");
}
#[test]
fn test_forecast_preserves_non_forecast_transactions() {
let plugin = ForecastPlugin;
let mut regular_txn = create_forecast_transaction("2024-01-15", "Regular purchase");
if let DirectiveData::Transaction(ref mut txn) = regular_txn.data {
txn.flag = "*".to_string(); }
let input = PluginInput {
directives: vec![regular_txn],
options: PluginOptions {
operating_currencies: vec!["USD".to_string()],
title: None,
},
config: None,
};
let output = plugin.process(input);
assert_eq!(output.directives.len(), 1);
if let DirectiveData::Transaction(txn) = &output.directives[0].data {
assert_eq!(txn.flag, "*");
assert_eq!(txn.narration, "Regular purchase");
}
}
#[test]
fn test_add_months() {
assert_eq!(
add_months(rustledger_core::naive_date(2024, 1, 15).unwrap(), 1),
rustledger_core::naive_date(2024, 2, 15).unwrap()
);
assert_eq!(
add_months(rustledger_core::naive_date(2024, 1, 31).unwrap(), 1),
rustledger_core::naive_date(2024, 2, 29).unwrap() );
assert_eq!(
add_months(rustledger_core::naive_date(2024, 11, 15).unwrap(), 3),
rustledger_core::naive_date(2025, 2, 15).unwrap()
);
}
}