use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use crate::collect::errors::Result as CollectResult;
use crate::collect::jira::jql_time::jql_date;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SyncScope {
pub project_key: String,
pub since: Option<DateTime<Utc>>,
}
pub fn build_jql(scope: &SyncScope, tz: Tz) -> CollectResult<String> {
Ok(match scope.since {
Some(since) => format!(
"project = {} AND updated >= \"{}\" ORDER BY updated ASC",
scope.project_key,
jql_date(since, tz)?
),
None => format!("project = {} ORDER BY updated ASC", scope.project_key),
})
}
pub fn resolve_scope(
project_key: &str,
explicit_since: Option<DateTime<Utc>>,
backfill: bool,
stored_cursor: Option<DateTime<Utc>>,
) -> SyncScope {
let since = if let Some(since) = explicit_since {
Some(since)
} else if backfill {
None
} else {
stored_cursor
};
SyncScope {
project_key: project_key.to_string(),
since,
}
}
pub fn next_cursor(observed_updated: &[DateTime<Utc>]) -> Option<DateTime<Utc>> {
observed_updated.iter().max().copied()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CursorPlan {
Advance(DateTime<Utc>),
Hold,
}
pub fn plan_cursor(
observed_updated: &[DateTime<Utc>],
failed_updated: &[Option<DateTime<Utc>>],
stored: Option<DateTime<Utc>>,
) -> CursorPlan {
if failed_updated.iter().any(Option::is_none) {
return CursorPlan::Hold;
}
if let Some(earliest_failure) = failed_updated.iter().flatten().min() {
return CursorPlan::Advance(*earliest_failure);
}
match next_cursor(observed_updated) {
Some(max) => CursorPlan::Advance(stored.map_or(max, |s| s.max(max))),
None => CursorPlan::Hold,
}
}
pub fn validate_project_key(key: &str) -> Result<(), String> {
let mut chars = key.chars();
let ok = match chars.next() {
Some(c) if c.is_ascii_alphabetic() => chars.all(|c| c.is_ascii_alphanumeric() || c == '_'),
_ => false,
};
if ok {
Ok(())
} else {
Err(format!(
"invalid JIRA project key '{key}': expected a leading ASCII letter \
followed by letters, digits or underscores (e.g. PROJ)"
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn dt(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s)
.expect("valid rfc3339 fixture")
.with_timezone(&Utc)
}
#[test]
fn jql_date_formats_at_minute_resolution() {
let d = Utc.with_ymd_and_hms(2026, 3, 4, 5, 6, 7).unwrap();
assert_eq!(jql_date(d, Tz::UTC).expect("renders"), "2026-03-04 05:06");
}
#[test]
fn build_jql_renders_the_bound_in_the_account_timezone() {
let scope = SyncScope {
project_key: "PROJ".into(),
since: Some(dt("2026-01-03T10:00:00Z")),
};
assert_eq!(
build_jql(&scope, Tz::America__New_York).expect("renders"),
"project = PROJ AND updated >= \"2026-01-03 05:00\" ORDER BY updated ASC"
);
}
#[test]
fn build_jql_without_since_omits_updated_clause() {
let scope = SyncScope {
project_key: "PROJ".into(),
since: None,
};
assert_eq!(
build_jql(&scope, Tz::UTC).expect("renders"),
"project = PROJ ORDER BY updated ASC"
);
}
#[test]
fn build_jql_with_since_includes_updated_clause() {
let scope = SyncScope {
project_key: "PROJ".into(),
since: Some(dt("2026-01-01T00:00:00Z")),
};
assert_eq!(
build_jql(&scope, Tz::UTC).expect("renders"),
"project = PROJ AND updated >= \"2026-01-01 00:00\" ORDER BY updated ASC"
);
}
#[test]
fn resolve_scope_backfill_without_since_is_full_history() {
let scope = resolve_scope("PROJ", None, true, Some(dt("2026-01-01T00:00:00Z")));
assert_eq!(
scope.since, None,
"explicit --backfill must override the stored cursor"
);
}
#[test]
fn resolve_scope_explicit_since_wins_over_backfill_and_cursor() {
let explicit = dt("2026-05-01T00:00:00Z");
let scope = resolve_scope(
"PROJ",
Some(explicit),
true,
Some(dt("2026-01-01T00:00:00Z")),
);
assert_eq!(scope.since, Some(explicit));
}
#[test]
fn resolve_scope_uses_stored_cursor_by_default() {
let cursor = dt("2026-01-01T00:00:00Z");
let scope = resolve_scope("PROJ", None, false, Some(cursor));
assert_eq!(scope.since, Some(cursor));
}
#[test]
fn resolve_scope_first_ever_sync_is_full_history() {
let scope = resolve_scope("PROJ", None, false, None);
assert_eq!(scope.since, None);
}
#[test]
fn next_cursor_returns_the_maximum_observed_timestamp() {
let observed = vec![
dt("2026-01-01T00:00:00Z"),
dt("2026-03-01T00:00:00Z"),
dt("2026-02-01T00:00:00Z"),
];
assert_eq!(next_cursor(&observed), Some(dt("2026-03-01T00:00:00Z")));
}
#[test]
fn next_cursor_returns_none_for_empty_batch() {
assert_eq!(next_cursor(&[]), None);
}
#[test]
fn plan_cursor_advances_to_max_when_nothing_failed() {
let observed = vec![dt("2026-01-01T00:00:00Z"), dt("2026-03-01T00:00:00Z")];
assert_eq!(
plan_cursor(&observed, &[], None),
CursorPlan::Advance(dt("2026-03-01T00:00:00Z"))
);
}
#[test]
fn plan_cursor_holds_on_an_empty_run() {
assert_eq!(plan_cursor(&[], &[], None), CursorPlan::Hold);
}
#[test]
fn plan_cursor_clamps_to_the_earliest_failure() {
let observed = vec![
dt("2026-01-01T00:00:00Z"),
dt("2026-01-03T00:00:00Z"),
dt("2026-06-01T00:00:00Z"),
];
let failed = vec![
Some(dt("2026-01-03T00:00:00Z")),
Some(dt("2026-04-01T00:00:00Z")),
];
assert_eq!(
plan_cursor(&observed, &failed, None),
CursorPlan::Advance(dt("2026-01-03T00:00:00Z")),
"the cursor must never move above the earliest failed ticket"
);
}
#[test]
fn plan_cursor_holds_when_a_failure_has_no_timestamp() {
let observed = vec![dt("2026-06-01T00:00:00Z")];
assert_eq!(plan_cursor(&observed, &[None], None), CursorPlan::Hold);
}
#[test]
fn clamped_cursor_keeps_the_failed_ticket_inside_the_next_window() {
for tz in [
Tz::UTC,
Tz::America__New_York,
Tz::Asia__Kolkata,
Tz::Pacific__Kiritimati,
] {
let failed_at = dt("2026-01-03T10:15:45Z");
let CursorPlan::Advance(next) =
plan_cursor(&[dt("2026-06-01T00:00:00Z")], &[Some(failed_at)], None)
else {
panic!("expected an advance");
};
let bound = jql_date(next, tz).expect("renders");
let naive = chrono::NaiveDateTime::parse_from_str(&bound, "%Y-%m-%d %H:%M")
.expect("renderer emits a parseable literal");
let window_opens = tz
.from_local_datetime(&naive)
.latest()
.expect("resolvable")
.with_timezone(&Utc);
assert!(
window_opens <= failed_at,
"{tz}: window opens at {window_opens}, after the failed ticket at \
{failed_at} — it would be skipped forever"
);
}
}
#[test]
fn plan_cursor_never_regresses_a_healthy_cursor() {
let stored = dt("2026-06-01T00:00:00Z");
let observed = vec![dt("2020-01-05T00:00:00Z"), dt("2020-02-01T00:00:00Z")];
assert_eq!(
plan_cursor(&observed, &[], Some(stored)),
CursorPlan::Advance(stored),
"a clean run must be monotonic in the cursor"
);
}
#[test]
fn plan_cursor_failure_clamp_may_regress_below_the_stored_cursor() {
let stored = dt("2026-06-01T00:00:00Z");
let failed_at = dt("2026-01-03T00:00:00Z");
assert_eq!(
plan_cursor(
&[dt("2026-06-01T00:00:00Z")],
&[Some(failed_at)],
Some(stored)
),
CursorPlan::Advance(failed_at),
"a held cursor must be able to move back to re-cover a failure"
);
}
#[test]
fn validate_project_key_accepts_conventional_keys() {
for key in ["PROJ", "API", "Proj2", "a_b9"] {
assert!(validate_project_key(key).is_ok(), "{key} must be accepted");
}
}
#[test]
fn validate_project_key_rejects_jql_metacharacters() {
for key in [
"",
"1PROJ",
"PROJ OR project = OTHER",
"PR\"OJ",
"PROJ-1",
"PROJ)",
] {
assert!(
validate_project_key(key).is_err(),
"{key:?} must be rejected before it reaches JQL"
);
}
}
}