agd-core 0.0.1

Org-mode parsing and task operations for agd
Documentation
use chrono::{DateTime, Datelike, Days, Duration, Local, Months, NaiveDate, TimeZone};

use crate::OrgModeError;
use crate::org_mode::{AgendaViewType, OrgMode};

impl OrgMode {
    pub(crate) fn to_start_of_day(date: DateTime<Local>) -> DateTime<Local> {
        date.date_naive()
            .and_hms_opt(0, 0, 0)
            .and_then(|dt| match Local.from_local_datetime(&dt) {
                chrono::LocalResult::Single(t) => Some(t),
                chrono::LocalResult::Ambiguous(t, _) => Some(t),
                chrono::LocalResult::None => {
                    let dt_plus_1 = dt + chrono::Duration::hours(1);
                    match Local.from_local_datetime(&dt_plus_1) {
                        chrono::LocalResult::Single(t) => Some(t),
                        chrono::LocalResult::Ambiguous(t, _) => Some(t),
                        chrono::LocalResult::None => None,
                    }
                }
            })
            .unwrap_or(date)
    }

    pub(crate) fn to_end_of_day(date: DateTime<Local>) -> DateTime<Local> {
        date.date_naive()
            .and_hms_opt(23, 59, 59)
            .and_then(|dt| match Local.from_local_datetime(&dt) {
                chrono::LocalResult::Single(t) => Some(t),
                chrono::LocalResult::Ambiguous(t, _) => Some(t),
                chrono::LocalResult::None => {
                    let dt_minus_1 = dt - chrono::Duration::hours(1);
                    match Local.from_local_datetime(&dt_minus_1) {
                        chrono::LocalResult::Single(t) => Some(t),
                        chrono::LocalResult::Ambiguous(t, _) => Some(t),
                        chrono::LocalResult::None => None,
                    }
                }
            })
            .unwrap_or(date)
    }

    pub(crate) fn naive_date_to_local(
        date: NaiveDate,
        hour: u32,
        min: u32,
        sec: u32,
    ) -> Result<DateTime<Local>, OrgModeError> {
        date.and_hms_opt(hour, min, sec)
            .and_then(|dt| match Local.from_local_datetime(&dt) {
                chrono::LocalResult::Single(t) => Some(t),
                chrono::LocalResult::Ambiguous(t, _) => Some(t),
                chrono::LocalResult::None => {
                    let dt_plus_1 = dt + chrono::Duration::hours(1);
                    match Local.from_local_datetime(&dt_plus_1) {
                        chrono::LocalResult::Single(t) => Some(t),
                        chrono::LocalResult::Ambiguous(t, _) => Some(t),
                        chrono::LocalResult::None => None,
                    }
                }
            })
            .ok_or_else(|| {
                OrgModeError::InvalidAgendaViewType(format!(
                    "Could not convert date '{}' to local timezone",
                    date
                ))
            })
    }

    pub(crate) fn last_day_of_month(date: DateTime<Local>) -> DateTime<Local> {
        let month = date.month();
        let year = date.year();

        let (next_month, next_year) = if month == 12 {
            (1, year + 1)
        } else {
            (month + 1, year)
        };

        let next_month_first = Self::to_start_of_day(
            date.with_year(next_year)
                .unwrap()
                .with_day(1)
                .unwrap()
                .with_month(next_month)
                .unwrap(),
        );

        next_month_first - Duration::days(1)
    }

    pub(crate) fn add_repeater_duration(
        date: DateTime<Local>,
        value: u64,
        unit: &orgize::ast::TimeUnit,
    ) -> DateTime<Local> {
        match unit {
            orgize::ast::TimeUnit::Hour => Some(date + Duration::hours(value as i64)),
            orgize::ast::TimeUnit::Day => date.checked_add_days(Days::new(value)),
            orgize::ast::TimeUnit::Week => date.checked_add_days(Days::new(value * 7)),
            orgize::ast::TimeUnit::Month => date.checked_add_months(Months::new(value as u32)),
            orgize::ast::TimeUnit::Year => date.checked_add_months(Months::new(value as u32 * 12)),
        }
        .unwrap_or(date)
    }

    pub(crate) fn parse_date_string(
        date_str: &str,
        context: &str,
    ) -> Result<NaiveDate, OrgModeError> {
        NaiveDate::parse_from_str(date_str, "%Y-%m-%d").map_err(|_| {
            OrgModeError::InvalidAgendaViewType(format!(
                "Invalid {context} '{date_str}', expected YYYY-MM-DD"
            ))
        })
    }
}

impl AgendaViewType {
    pub fn start_date(&self) -> DateTime<Local> {
        let date = match self {
            AgendaViewType::Today => Local::now(),
            AgendaViewType::Day(d) => *d,
            AgendaViewType::CurrentWeek => {
                let now = Local::now();
                let weekday = now.weekday().num_days_from_monday();
                now - Duration::days(weekday as i64)
            }
            AgendaViewType::Week(week_num) => {
                let now = Local::now();
                let year_start =
                    OrgMode::to_start_of_day(now.with_month(1).unwrap().with_day(1).unwrap());
                year_start + Duration::weeks(*week_num as i64)
            }
            AgendaViewType::CurrentMonth => {
                let now = Local::now();
                now.with_day(1).unwrap()
            }
            AgendaViewType::Month(month) => {
                let now = Local::now().with_day(1).unwrap_or(Local::now());
                now.with_month(*month).unwrap_or(now).with_day(1).unwrap()
            }
            AgendaViewType::Custom { from, .. } => *from,
        };
        OrgMode::to_start_of_day(date)
    }

    pub fn end_date(&self) -> DateTime<Local> {
        let date = match self {
            AgendaViewType::Today => Local::now(),
            AgendaViewType::Day(d) => *d,
            AgendaViewType::CurrentWeek => {
                let now = Local::now();
                let weekday = now.weekday().num_days_from_monday();
                let start = now - Duration::days(weekday as i64);
                start + Duration::days(6)
            }
            AgendaViewType::Week(week_num) => {
                let now = Local::now();
                let year_start =
                    OrgMode::to_start_of_day(now.with_month(1).unwrap().with_day(1).unwrap());
                let target_week_start = year_start + Duration::weeks(*week_num as i64);
                target_week_start + Duration::days(6)
            }
            AgendaViewType::CurrentMonth => {
                let now = Local::now();
                OrgMode::last_day_of_month(now)
            }
            AgendaViewType::Month(month) => {
                let now = Local::now().with_day(1).unwrap_or(Local::now());
                let date_in_month = now.with_month(*month).unwrap_or(now);
                OrgMode::last_day_of_month(date_in_month)
            }
            AgendaViewType::Custom { to, .. } => *to,
        };
        OrgMode::to_end_of_day(date)
    }
}

impl TryFrom<&str> for AgendaViewType {
    type Error = OrgModeError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        if value.is_empty() {
            return Ok(AgendaViewType::default());
        }

        match value {
            "today" => Ok(AgendaViewType::Today),
            "week" => Ok(AgendaViewType::CurrentWeek),
            "month" => Ok(AgendaViewType::CurrentMonth),
            _ => {
                let parts: Vec<&str> = value.split('/').collect();

                match parts.as_slice() {
                    ["day", date_str] => {
                        let parsed_date = OrgMode::parse_date_string(date_str, "date format")?;
                        let datetime = OrgMode::naive_date_to_local(parsed_date, 0, 0, 0)?;
                        Ok(AgendaViewType::Day(datetime))
                    }
                    ["week", week_str] => {
                        let week_num = week_str.parse::<u8>().map_err(|_| {
                            OrgModeError::InvalidAgendaViewType(format!(
                                "Invalid week number '{}', expected 0-53",
                                week_str
                            ))
                        })?;
                        if week_num > 53 {
                            return Err(OrgModeError::InvalidAgendaViewType(format!(
                                "Week number {} out of range, expected 0-53",
                                week_num
                            )));
                        }
                        Ok(AgendaViewType::Week(week_num))
                    }
                    ["month", month_str] => {
                        let month_num = month_str.parse::<u32>().map_err(|_| {
                            OrgModeError::InvalidAgendaViewType(format!(
                                "Invalid month number '{}', expected 1-12",
                                month_str
                            ))
                        })?;
                        if !(1..=12).contains(&month_num) {
                            return Err(OrgModeError::InvalidAgendaViewType(format!(
                                "Month number {} out of range, expected 1-12",
                                month_num
                            )));
                        }
                        Ok(AgendaViewType::Month(month_num))
                    }
                    ["query", "from", from_str, "to", to_str] => {
                        let from_date = OrgMode::parse_date_string(from_str, "from date")?;
                        let to_date = OrgMode::parse_date_string(to_str, "to date")?;

                        let from_datetime = OrgMode::naive_date_to_local(from_date, 0, 0, 0)?;
                        let to_datetime = OrgMode::naive_date_to_local(to_date, 23, 59, 59)?;

                        if from_datetime > to_datetime {
                            return Err(OrgModeError::InvalidAgendaViewType(
                                "From date must be before to date".into(),
                            ));
                        }
                        Ok(AgendaViewType::Custom {
                            from: from_datetime,
                            to: to_datetime,
                        })
                    }
                    _ => Err(OrgModeError::InvalidAgendaViewType(format!(
                        "Unknown agenda view type format: '{}'",
                        value
                    ))),
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::Timelike;
    use orgize::ast::TimeUnit;

    #[test]
    fn test_add_repeater_duration_hour() {
        let date = Local.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap();
        let result = OrgMode::add_repeater_duration(date, 2, &TimeUnit::Hour);

        assert_eq!(result.hour(), 16);
        assert_eq!(result.day(), 15);
    }

    #[test]
    fn test_add_repeater_duration_day() {
        let date = Local.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
        let result = OrgMode::add_repeater_duration(date, 5, &TimeUnit::Day);

        assert_eq!(result.day(), 20);
        assert_eq!(result.month(), 6);
    }

    #[test]
    fn test_add_repeater_duration_week() {
        let date = Local.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
        let result = OrgMode::add_repeater_duration(date, 2, &TimeUnit::Week);

        assert_eq!(result.day(), 29);
        assert_eq!(result.month(), 6);
    }

    #[test]
    fn test_add_repeater_duration_month() {
        let date = Local.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
        let result = OrgMode::add_repeater_duration(date, 3, &TimeUnit::Month);

        assert_eq!(result.month(), 9);
        assert_eq!(result.day(), 15);
        assert_eq!(result.year(), 2025);
    }

    #[test]
    fn test_add_repeater_duration_year() {
        let date = Local.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
        let result = OrgMode::add_repeater_duration(date, 2, &TimeUnit::Year);

        assert_eq!(result.year(), 2027);
        assert_eq!(result.month(), 6);
        assert_eq!(result.day(), 15);
    }

    #[test]
    fn test_add_repeater_duration_month_boundary() {
        let date = Local.with_ymd_and_hms(2025, 10, 15, 12, 0, 0).unwrap();
        let result = OrgMode::add_repeater_duration(date, 3, &TimeUnit::Month);

        assert_eq!(result.year(), 2026);
        assert_eq!(result.month(), 1);
        assert_eq!(result.day(), 15);
    }

    #[test]
    fn test_last_day_of_month_from_day_31() {
        let date = Local.with_ymd_and_hms(2025, 1, 31, 12, 0, 0).unwrap();
        let result = OrgMode::last_day_of_month(date);

        assert_eq!(result.month(), 1);
        assert_eq!(result.day(), 31);
    }

    #[test]
    fn test_last_day_of_month_february() {
        let date = Local.with_ymd_and_hms(2025, 2, 15, 12, 0, 0).unwrap();
        let result = OrgMode::last_day_of_month(date);

        assert_eq!(result.month(), 2);
        assert_eq!(result.day(), 28);
    }

    #[test]
    fn test_last_day_of_month_leap_year() {
        let date = Local.with_ymd_and_hms(2024, 2, 15, 12, 0, 0).unwrap();
        let result = OrgMode::last_day_of_month(date);

        assert_eq!(result.month(), 2);
        assert_eq!(result.day(), 29);
    }
}