jobber 1.1.2-alpha

Minimalistic console work time tracker
use crate::{
    commands::{Error, Result},
    entities::{DateTime, Duration, NaiveDate, NaiveDateTime, NaiveTime},
};

pub(crate) fn from_start(user_input: &impl ToString) -> Result<DateTime> {
    from_start_ex(user_input, DateTime::now())
}

pub(crate) fn from_start_ex(user_input: &impl ToString, now: DateTime) -> Result<DateTime> {
    match interpret(&user_input.to_string())? {
        Interpretation::Now => Ok(now),
        Interpretation::DateTime(naive_date_time) => Ok(DateTime::from_naive(naive_date_time)),
        Interpretation::Time(naive_time) => Ok(now.with_time(naive_time)),
        Interpretation::Relative(time_delta) => Ok(now.with_delta(time_delta)),
        _ => todo!(),
    }
}

pub(crate) fn from_end(user_input: &impl ToString, start: DateTime) -> Result<DateTime> {
    from_end_ex(user_input, start, DateTime::now())
}

pub(crate) fn from_end_ex(
    user_input: &impl ToString,
    start: DateTime,
    now: DateTime,
) -> Result<DateTime> {
    match interpret(&user_input.to_string())? {
        Interpretation::Now => Ok(now),
        Interpretation::DateTime(naive_date_time) => Ok(DateTime::from_naive(naive_date_time)),
        Interpretation::Time(naive_time) => {
            let dt = now.with_time(naive_time);
            // fix wrong order of start and end
            let dt = if start > dt {
                dt + Duration::days(1)
            } else {
                dt
            };
            Ok(dt)
        }
        // HACK: invert time_delta
        Interpretation::Relative(time_delta) => Ok(start - time_delta),
        _ => todo!(),
    }
}

#[allow(unused)]
pub(crate) fn from_range_days(
    user_input_start: &impl ToString,
    user_input_end: &Option<impl ToString>,

    now: DateTime,
) -> Result<(DateTime, DateTime)> {
    from_range_ex(user_input_start, user_input_end, false, now)
}

pub(crate) fn from_range_months(
    user_input_start: &impl ToString,
    user_input_end: &Option<impl ToString>,
    now: DateTime,
) -> Result<(DateTime, DateTime)> {
    from_range_ex(user_input_start, user_input_end, true, now)
}

fn from_range_ex(
    user_input_start: &impl ToString,
    user_input_end: &Option<impl ToString>,
    single_number_is_month: bool,
    now: DateTime,
) -> Result<(DateTime, DateTime)> {
    let start = match interpret(&user_input_start.to_string())? {
        Interpretation::Now => now,
        Interpretation::DateTime(naive_date_time) => DateTime::from_naive(naive_date_time),
        Interpretation::Date(naive_date) => DateTime::from_naive_date(naive_date),
        Interpretation::DayMonth(day, month) => {
            if user_input_end.is_none() {
                return Ok((
                    DateTime::from_ymd(now.year(), month, day)?,
                    DateTime::from_ymd(now.year(), month, day + 1)?,
                ));
            } else {
                DateTime::from_ymd(now.year(), month, day)?
            }
        }
        Interpretation::MonthYear(month, year) => {
            if user_input_end.is_none() {
                return Ok((
                    DateTime::from_ymd(year, month, 1)?,
                    DateTime::from_ymd(year, month + 1, 1)?,
                ));
            } else {
                DateTime::from_ymd(year, month, 1)?
            }
        }
        Interpretation::Year(year) => {
            if user_input_end.is_none() {
                return Ok((
                    DateTime::from_ymd(year, 1, 1)?,
                    DateTime::from_ymd(year + 1, 1, 1)?,
                ));
            } else {
                DateTime::from_ymd(year, 1, 1)?
            }
        }
        Interpretation::Day(day) => {
            if single_number_is_month {
                return Err(Error::InvalidRange(
                    user_input_start.to_string(),
                    user_input_end
                        .as_ref()
                        .map(|s| s.to_string())
                        .unwrap_or_default(),
                ));
            } else {
                DateTime::from_ymd(now.year(), 1, day)?
            }
        }
        Interpretation::MonthOrDay(month_or_day) => {
            if single_number_is_month {
                DateTime::from_ymd(now.year(), month_or_day, 1)?
            } else {
                DateTime::from_ymd(now.year(), 1, month_or_day)?
            }
        }
        Interpretation::Time(_) | Interpretation::Relative(_) => {
            return Err(Error::InvalidRange(
                user_input_start.to_string(),
                user_input_end
                    .as_ref()
                    .map(|s| s.to_string())
                    .unwrap_or_default(),
            ));
        }
    };
    let end = if let Some(end) = user_input_end {
        match interpret(&end.to_string())? {
            Interpretation::Now => now,
            Interpretation::DateTime(naive_date_time) => DateTime::from_naive(naive_date_time),
            Interpretation::Date(naive_date) => DateTime::from_naive_date(naive_date),
            Interpretation::Year(year) => DateTime::from_ymd(year + 1, 1, 1)?,
            Interpretation::DayMonth(day, month) => DateTime::from_ymd(start.year(), month, day)?,
            Interpretation::MonthYear(month, year) => DateTime::from_ymd(year, month, 1)?,
            Interpretation::Day(day) => {
                if single_number_is_month {
                    return Err(Error::InvalidRange(
                        user_input_start.to_string(),
                        user_input_end
                            .as_ref()
                            .map(|s| s.to_string())
                            .unwrap_or_default(),
                    ));
                } else {
                    DateTime::from_ymd(now.year(), 1, day + 1)?
                }
            }
            Interpretation::MonthOrDay(month_or_day) => {
                if single_number_is_month {
                    DateTime::from_ymd(now.year(), month_or_day + 1, 1)?
                } else {
                    DateTime::from_ymd(now.year(), 1, month_or_day + 1)?
                }
            }
            Interpretation::Time(_) | Interpretation::Relative(_) => {
                return Err(Error::InvalidRange(
                    user_input_start.to_string(),
                    user_input_end
                        .as_ref()
                        .map(|s| s.to_string())
                        .unwrap_or_default(),
                ));
            }
        }
    } else {
        start.end_of_day()
    };
    Ok((start, end))
}

/// User input interpretations
#[derive(Eq, PartialEq, Debug)]
enum Interpretation {
    Now,
    DateTime(NaiveDateTime),
    Time(NaiveTime),
    Date(NaiveDate),
    Year(i32),
    Day(u32),
    MonthOrDay(u32),
    DayMonth(u32, u32),
    MonthYear(u32, i32),
    Relative(Duration),
}

fn interpret(user_input: &str) -> Result<Interpretation> {
    if user_input.to_lowercase() == "now" {
        return Ok(Interpretation::Now);
    }

    if let Ok(time) = chrono::NaiveTime::parse_from_str(user_input, "%H:%M") {
        return Ok(Interpretation::Time(time));
    }

    if let Ok(date_time) = chrono::NaiveDateTime::parse_from_str(user_input, "%Y-%m-%d %H:%M") {
        return Ok(Interpretation::DateTime(date_time));
    }

    if let Ok(date) = chrono::NaiveDate::parse_from_str(user_input, "%Y-%m-%d") {
        return Ok(Interpretation::Date(date));
    }

    // format e.g.: '5m', '+10h'
    let factor: i64 = if user_input.starts_with('+') { 1 } else { -1 };
    if user_input.ends_with('m')
        && let Ok(m) = user_input[0..user_input.len() - 1].parse::<u32>()
    {
        return Ok(Interpretation::Relative(chrono::TimeDelta::minutes(
            factor * m as i64,
        )));
    } else if user_input.ends_with('h')
        && let Ok(m) = user_input[0..user_input.len() - 1].parse::<u32>()
    {
        return Ok(Interpretation::Relative(chrono::TimeDelta::hours(
            factor * m as i64,
        )));
    }

    // format e.g.: YYYY, M/YYYY, M/D, D and M
    let split: Vec<_> = user_input.split('/').collect();
    match split.len() {
        1 => {
            let input: u32 = split[0].parse()?;
            match input {
                1..13 => {
                    return Ok(Interpretation::MonthOrDay(input));
                }
                13..32 => {
                    return Ok(Interpretation::Day(input));
                }
                1000..10000 => {
                    return Ok(Interpretation::Year(input as i32));
                }
                _ => (),
            }
        }
        2 => {
            let left: u32 = split[0].parse()?;
            let right: u32 = split[1].parse()?;
            match right {
                1..32 => {
                    return Ok(Interpretation::DayMonth(right, left));
                }
                1000..10000 => {
                    return Ok(Interpretation::MonthYear(left, right as i32));
                }
                _ => (),
            }
        }
        _ => (),
    }

    Err(Error::CannotConvertDateTime(user_input.to_string()))
}

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

    fn time(time: &str) -> Interpretation {
        Interpretation::Time(chrono::NaiveTime::parse_from_str(time, "%H:%M").unwrap())
    }

    #[test]
    fn interpret_test() {
        assert_eq!(interpret("now").unwrap(), Interpretation::Now);
        assert_eq!(interpret("1:00").unwrap(), time("1:00"));
        assert_eq!(interpret("12:00").unwrap(), time("12:00"));
        assert!(interpret("25:00").is_err(),);
        interpret("5m").unwrap();
        interpret("2h").unwrap();
        interpret("+5m").unwrap();
        interpret("+2h").unwrap();
    }

    #[test]
    fn from_range_test() {
        let now = DateTime::from_ymd(2026, 6, 1).unwrap();
        assert_eq!(
            (
                chrono::NaiveDateTime::parse_from_str("2026-01-01 00:00", "%Y-%m-%d %H:%M")
                    .unwrap()
                    .and_local_timezone(chrono::Local)
                    .unwrap()
                    .into(),
                chrono::NaiveDateTime::parse_from_str("2027-01-01 00:00", "%Y-%m-%d %H:%M")
                    .unwrap()
                    .and_local_timezone(chrono::Local)
                    .unwrap()
                    .into(),
            ),
            from_range_months(&"2026", &Option::<&str>::None, now)
                .map(|(l, r)| (l.as_chrono_utc(), r.as_chrono_utc()))
                .unwrap()
        );
    }
}