tcalc_core 0.2.0

Core library for tcalc-cli crate.
Documentation
mod calendar;
mod evaluator;
mod lexer;
mod parser;

use crate::evaluator::eval_with_calendar;
use crate::lexer::Lexer;
use crate::parser::{Expr, parse};
use toml::Value;

pub use crate::calendar::Calendar;

pub fn run(input: &str, calendar: Option<&Calendar>) -> Result<String, String> {
    let default_calendar = Calendar::default();
    let calendar = calendar.unwrap_or(&default_calendar);
    let tokens = Lexer::new(input);
    let ast = parse(tokens).map_err(|err| format!("failed to parse expression: {}", err))?;
    let result = eval_with_calendar(&ast, calendar)
        .map_err(|err| format!("failed to evaluate expression: {}", err))?;
    Ok(result.to_string())
}

pub fn calendar_from_holidays(holidays: &[String]) -> Result<Calendar, String> {
    let mut calendar = Calendar::new();

    for holiday in holidays {
        add_holiday_to_calendar(&mut calendar, holiday)?;
    }

    Ok(calendar)
}

pub fn calendar_from_toml(input: &str, calendar_name: Option<&str>) -> Result<Calendar, String> {
    let value = input
        .parse::<Value>()
        .map_err(|err| format!("failed to parse calendar file: {}", err))?;

    let table = match calendar_name {
        Some(name) => value
            .get(name)
            .ok_or_else(|| format!("calendar '{}' not found", name))?,
        None => &value,
    };

    let holidays = table
        .get("holidays")
        .ok_or_else(|| missing_holidays_error(calendar_name))?
        .as_array()
        .ok_or_else(|| holidays_type_error(calendar_name))?;

    let mut calendar = Calendar::new();
    for holiday in holidays {
        let holiday = holiday
            .as_str()
            .ok_or_else(|| holidays_type_error(calendar_name))?;
        add_holiday_to_calendar(&mut calendar, holiday)?;
    }

    Ok(calendar)
}

fn missing_holidays_error(calendar_name: Option<&str>) -> String {
    match calendar_name {
        Some(name) => format!("calendar '{}' must define holidays", name),
        None => "calendar file must define top-level holidays or use --calendar-name".to_string(),
    }
}

fn holidays_type_error(calendar_name: Option<&str>) -> String {
    match calendar_name {
        Some(name) => format!(
            "calendar '{}' holidays must be an array of date strings",
            name
        ),
        None => "calendar holidays must be an array of date strings".to_string(),
    }
}

fn add_holiday_to_calendar(calendar: &mut Calendar, holiday: &str) -> Result<(), String> {
    let tokens = Lexer::new(holiday);
    let ast =
        parse(tokens).map_err(|err| format!("failed to parse holiday '{}': {}", holiday, err))?;

    match ast {
        Expr::Date(year, month, day) => calendar
            .add_holiday_ymd(year, month, day)
            .map_err(|err| format!("invalid holiday '{}': {}", holiday, err)),
        _ => Err(format!("holiday '{}' must be a date", holiday)),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn run_skips_holidays_with_calendar() {
        let holidays = vec!["2024/04/29".to_string()];
        let calendar = calendar_from_holidays(&holidays).unwrap();

        let result = run("2024/04/26 + 1wd", Some(&calendar)).unwrap();

        assert_eq!(result, "2024-04-30");
    }

    #[test]
    fn calendar_from_holidays_rejects_non_date() {
        let holidays = vec!["2h".to_string()];

        assert!(calendar_from_holidays(&holidays).is_err());
    }

    #[test]
    fn calendar_from_toml_reads_top_level_holidays() {
        let calendar = calendar_from_toml(
            r#"
            holidays = ["2024/04/29"]
            "#,
            None,
        )
        .unwrap();

        let result = run("2024/04/26 + 1wd", Some(&calendar)).unwrap();

        assert_eq!(result, "2024-04-30");
    }

    #[test]
    fn calendar_from_toml_reads_named_calendar() {
        let calendar = calendar_from_toml(
            r#"
            [italy]
            holidays = ["2024/04/29"]
            "#,
            Some("italy"),
        )
        .unwrap();

        let result = run("2024/04/26 + 1wd", Some(&calendar)).unwrap();

        assert_eq!(result, "2024-04-30");
    }

    #[test]
    fn calendar_from_toml_requires_name_for_named_calendar_only_file() {
        let result = calendar_from_toml(
            r#"
            [italy]
            holidays = ["2024/04/29"]
            "#,
            None,
        );

        assert!(result.is_err());
    }
}