use chrono::{DateTime, Duration, LocalResult, NaiveDateTime, TimeZone, Timelike, Utc};
use chrono_tz::Tz;
use crate::collect::errors::{CollectError, Result};
const MAX_STEP_BACK_MINUTES: i64 = 1560;
pub fn parse_timezone(name: &str) -> Result<Tz> {
name.parse::<Tz>().map_err(|_| {
CollectError::Config(format!(
"unrecognised JIRA timezone '{name}': expected an IANA name such as \
`UTC` or `America/New_York`. Set `jira.timezone` in config.yaml to \
override what the JIRA account reports."
))
})
}
pub fn jql_date(dt: DateTime<Utc>, tz: Tz) -> Result<String> {
let mut candidate = truncate_to_minute(dt.with_timezone(&tz).naive_local());
for _ in 0..MAX_STEP_BACK_MINUTES {
match worst_case_instant(candidate, tz) {
Some(resolved) if resolved <= dt => return Ok(format_literal(candidate)),
_ => candidate -= Duration::minutes(1),
}
}
Err(CollectError::Config(format!(
"could not render a safe JQL date bound for {dt} in timezone '{tz}': no \
literal within {MAX_STEP_BACK_MINUTES} minutes below it resolves to an \
instant at or before it. This means the zone presents an offset range \
wider than the IANA −12:00…+14:00 span. Set `jira.timezone` to `UTC` in \
config.yaml to bypass zone resolution."
)))
}
fn worst_case_instant(naive: NaiveDateTime, tz: Tz) -> Option<DateTime<Utc>> {
match tz.from_local_datetime(&naive) {
LocalResult::Single(t) => Some(t.with_timezone(&Utc)),
LocalResult::Ambiguous(a, b) => Some(a.max(b).with_timezone(&Utc)),
LocalResult::None => None,
}
}
fn truncate_to_minute(naive: NaiveDateTime) -> NaiveDateTime {
naive
.with_second(0)
.and_then(|n| n.with_nanosecond(0))
.unwrap_or(naive)
}
fn format_literal(naive: NaiveDateTime) -> String {
naive.format("%Y-%m-%d %H:%M").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn dt(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s)
.expect("valid rfc3339 fixture")
.with_timezone(&Utc)
}
fn evaluate(literal: &str, tz: Tz) -> DateTime<Utc> {
let naive = NaiveDateTime::parse_from_str(literal, "%Y-%m-%d %H:%M")
.expect("renderer emits a parseable literal");
worst_case_instant(naive, tz).expect("renderer never emits a nonexistent local time")
}
#[test]
fn parse_timezone_accepts_iana_names() {
assert_eq!(parse_timezone("UTC").expect("utc"), Tz::UTC);
assert_eq!(
parse_timezone("America/New_York").expect("ny"),
Tz::America__New_York
);
}
#[test]
fn parse_timezone_rejects_garbage() {
let err = parse_timezone("Mars/Olympus").expect_err("must reject");
assert!(
err.to_string().contains("Mars/Olympus"),
"must name the value: {err}"
);
}
#[test]
fn jql_date_is_identity_on_a_utc_account() {
assert_eq!(
jql_date(dt("2026-01-03T10:15:45Z"), Tz::UTC).expect("renders"),
"2026-01-03 10:15"
);
}
#[test]
fn jql_date_never_opens_after_the_intended_instant() {
let zones = [
Tz::UTC,
Tz::America__New_York, Tz::America__Los_Angeles,
Tz::Europe__Amsterdam, Tz::Asia__Kolkata, Tz::Pacific__Kiritimati, Tz::Pacific__Midway, ];
let instants = [
"2026-01-03T10:15:45Z",
"2026-06-15T23:59:59Z",
"2026-03-08T06:30:00Z", "2026-11-01T05:30:00Z", "2026-01-01T00:00:00Z",
];
for tz in zones {
for instant in instants {
let intended = dt(instant);
let literal = jql_date(intended, tz).expect("renders");
let evaluated = evaluate(&literal, tz);
assert!(
evaluated <= intended,
"{tz} @ {instant}: bound `{literal}` evaluates to {evaluated}, \
which is AFTER the instant it must re-cover"
);
}
}
}
#[test]
fn jql_date_stays_within_a_minute_below_the_intended_instant() {
for tz in [Tz::UTC, Tz::America__New_York, Tz::Asia__Kolkata] {
let intended = dt("2026-06-15T12:34:56Z");
let evaluated = evaluate(&jql_date(intended, tz).expect("renders"), tz);
assert!(
intended - evaluated < Duration::minutes(1),
"{tz}: bound regressed {} below the intended instant",
intended - evaluated
);
}
}
#[test]
fn jql_date_is_safe_across_a_dst_fall_back_fold() {
let tz = Tz::America__New_York;
for minute in 0..60 {
let intended = dt("2026-11-01T05:00:00Z") + Duration::minutes(minute);
let literal = jql_date(intended, tz).expect("renders");
let evaluated = evaluate(&literal, tz);
assert!(
evaluated <= intended,
"fold minute {minute}: `{literal}` -> {evaluated} > {intended}"
);
}
}
#[test]
fn jql_date_is_safe_across_the_largest_fold_in_the_tz_database() {
let tz = Tz::Antarctica__Casey;
let start = dt("2010-03-04T06:00:00Z");
for minute in 0..(24 * 60) {
let intended = start + Duration::minutes(minute);
let literal = jql_date(intended, tz)
.unwrap_or_else(|e| panic!("minute {minute}: bound must be renderable: {e}"));
let evaluated = evaluate(&literal, tz);
assert!(
evaluated <= intended,
"minute {minute}: `{literal}` -> {evaluated} > {intended}"
);
assert!(
intended - evaluated <= Duration::hours(3),
"minute {minute}: stepped back {} — the search must converge on \
the fold width, not run to the ceiling",
intended - evaluated
);
}
}
#[test]
fn jql_date_is_safe_across_a_dst_spring_forward_gap() {
let tz = Tz::America__New_York;
for minute in 0..60 {
let intended = dt("2026-03-08T07:00:00Z") + Duration::minutes(minute);
let literal = jql_date(intended, tz).expect("renders");
let evaluated = evaluate(&literal, tz);
assert!(evaluated <= intended, "gap minute {minute}: `{literal}`");
}
}
}