use std::collections::BTreeMap;
use std::collections::HashSet;
use std::fmt;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::config::StableId;
use crate::error::{Result, TixError};
use crate::recurrence::rule::{IntervalUnit, RecurrenceRule, RuleKind, Timezone};
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct CycleScheduleId(String);
impl StableId for CycleScheduleId {
const PREFIX: &'static str = "cs-";
const LABEL: &'static str = "cycle schedule id";
const ARTICLE: &'static str = "a";
fn from_validated(raw: String) -> Self {
Self(raw)
}
fn as_str(&self) -> &str {
&self.0
}
}
impl CycleScheduleId {
pub const MAX_LEN: usize = <Self as StableId>::MAX_LEN;
pub fn new(raw: &str) -> Result<Self> {
<Self as StableId>::new(raw)
}
#[must_use]
pub fn generate() -> Self {
<Self as StableId>::generate()
}
#[must_use]
pub fn as_str(&self) -> &str {
<Self as StableId>::as_str(self)
}
}
impl fmt::Display for CycleScheduleId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for CycleScheduleId {
type Error = TixError;
fn try_from(raw: String) -> Result<Self> {
Self::new(&raw)
}
}
impl From<CycleScheduleId> for String {
fn from(id: CycleScheduleId) -> Self {
id.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ScheduleState {
#[default]
Active,
Paused,
Ended,
}
impl ScheduleState {
pub const fn as_str(self) -> &'static str {
match self {
ScheduleState::Active => "active",
ScheduleState::Paused => "paused",
ScheduleState::Ended => "ended",
}
}
pub const fn is_terminal(self) -> bool {
matches!(self, ScheduleState::Ended)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CycleScheduleExtensions(toml::Table);
impl CycleScheduleExtensions {
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&toml::Value> {
self.0.get(key)
}
pub fn keys(&self) -> impl Iterator<Item = &str> {
self.0.keys().map(String::as_str)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &toml::Value)> {
self.0.iter().map(|(k, v)| (k.as_str(), v))
}
#[must_use]
pub fn table(&self) -> &toml::Table {
&self.0
}
pub fn insert(&mut self, key: impl Into<String>, value: toml::Value) -> Option<toml::Value> {
self.0.insert(key.into(), value)
}
}
impl PartialEq for CycleScheduleExtensions {
fn eq(&self, other: &Self) -> bool {
toml::Value::Table(self.0.clone()).to_string()
== toml::Value::Table(other.0.clone()).to_string()
}
}
impl Eq for CycleScheduleExtensions {}
impl From<toml::Table> for CycleScheduleExtensions {
fn from(table: toml::Table) -> Self {
Self(table)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Span {
Days(u64),
Months(u64),
}
impl Span {
fn overlaps_next(self, other: Self) -> Option<bool> {
match (self, other) {
(Span::Days(step), Span::Days(duration)) => Some(step < duration),
(Span::Months(step), Span::Months(duration)) => Some(step < duration),
_ => None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Cadence {
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<RuleKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub interval: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<IntervalUnit>,
#[serde(flatten)]
pub extensions: CycleScheduleExtensions,
}
impl Cadence {
#[must_use]
pub fn new(kind: RuleKind) -> Self {
Self {
kind: Some(kind),
..Self::default()
}
}
#[must_use]
pub fn with_interval(mut self, interval: u32) -> Self {
self.interval = Some(interval);
self
}
#[must_use]
pub fn with_unit(mut self, unit: IntervalUnit) -> Self {
self.unit = Some(unit);
self
}
fn span(&self) -> Option<Span> {
let interval = u64::from(self.interval.unwrap_or(1));
match self.kind? {
RuleKind::Daily => Some(Span::Days(interval)),
RuleKind::Weekly => Some(Span::Days(interval.saturating_mul(7))),
RuleKind::Monthly => Some(Span::Months(interval)),
RuleKind::Interval => match self.unit? {
IntervalUnit::Days => Some(Span::Days(interval)),
IntervalUnit::Weeks => Some(Span::Days(interval.saturating_mul(7))),
IntervalUnit::Months => Some(Span::Months(interval)),
},
RuleKind::CronLike => None,
}
}
const KNOWN_FIELDS: &'static [&'static str] = &["kind", "interval", "unit"];
#[must_use]
pub fn to_rule(&self, schedule: &CycleScheduleConfiguration) -> Option<RecurrenceRule> {
Some(RecurrenceRule {
kind: self.kind?,
interval: self.interval,
anchor: Some(schedule.start_date.clone()),
timezone: schedule.timezone.clone(),
expression: None,
unit: self.unit,
extensions: BTreeMap::new(),
})
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CycleDuration {
pub count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<IntervalUnit>,
#[serde(flatten)]
pub extensions: CycleScheduleExtensions,
}
impl CycleDuration {
#[must_use]
pub fn new(count: u32, unit: IntervalUnit) -> Self {
Self {
count,
unit: Some(unit),
extensions: CycleScheduleExtensions::default(),
}
}
fn span(&self) -> Option<Span> {
let count = u64::from(self.count);
match self.unit? {
IntervalUnit::Days => Some(Span::Days(count)),
IntervalUnit::Weeks => Some(Span::Days(count.saturating_mul(7))),
IntervalUnit::Months => Some(Span::Months(count)),
}
}
const KNOWN_FIELDS: &'static [&'static str] = &["count", "unit"];
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CycleScheduleConfiguration {
pub name: String,
pub cadence: Cadence,
pub duration: CycleDuration,
pub start_date: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub timezone: Option<Timezone>,
pub look_ahead: u32,
pub state: ScheduleState,
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<crate::cycle_schedule::identity::GenerationCursor>,
#[serde(flatten)]
pub extensions: CycleScheduleExtensions,
}
pub const CYCLE_SCHEDULE_KNOWN_FIELDS: &[&str] = &[
"name",
"cadence",
"duration",
"start_date",
"timezone",
"look_ahead",
"state",
"cursor",
];
impl CycleScheduleConfiguration {
fn path(id: &str) -> String {
format!("cycle_schedules.{id}")
}
fn check_extensions(
id: &str,
subject: &str,
known: &[&str],
extensions: &CycleScheduleExtensions,
) -> Result<()> {
for key in extensions.keys() {
if known.contains(&key) {
let path = if subject.is_empty() {
format!("cycle_schedules.{id}.{key}")
} else {
format!("cycle_schedules.{id}.{subject}.{key}")
};
return Err(TixError::Config(format!(
"`{path}` collides with a built-in field; rename the extension key"
)));
}
}
Ok(())
}
}
#[allow(clippy::too_many_lines)]
pub fn validate_schedules(schedules: &IndexMap<String, CycleScheduleConfiguration>) -> Result<()> {
let mut seen_names: HashSet<String> = HashSet::new();
for (id, schedule) in schedules {
crate::config::validate_registry_key::<CycleScheduleId>("cycle_schedules", id)?;
crate::config::validate_display_name(
"cycle_schedules",
id,
&schedule.name,
"cycle schedule",
&mut seen_names,
)?;
CycleScheduleConfiguration::check_extensions(
id,
"",
CYCLE_SCHEDULE_KNOWN_FIELDS,
&schedule.extensions,
)?;
let path = CycleScheduleConfiguration::path(id);
if !crate::model::validate_date(&schedule.start_date) {
return Err(TixError::Config(format!(
"`{path}.start_date` (`{}`) must be a `YYYY-MM-DD` calendar date",
schedule.start_date
)));
}
match schedule.timezone.as_ref() {
None => {
return Err(TixError::Config(format!(
"`{path}.timezone` is required: give an explicit fixed UTC offset \
(`+01:00`, `UTC`); the host zone is never used implicitly"
)));
}
Some(Timezone::Named(raw)) => {
return Err(TixError::Config(format!(
"`{path}.timezone` (`{raw}`) is a named zone; only a fixed UTC offset is \
supported (daylight-saving transitions need a reviewed tzdb — see \
docs/cycle-schedule-dependency-review.md)"
)));
}
Some(Timezone::Invalid(raw)) => {
return Err(TixError::Config(format!(
"`{path}.timezone` (`{raw}`) is not a fixed UTC offset; use `±HH:MM` or `UTC`"
)));
}
Some(Timezone::Fixed(_)) => {}
}
CycleScheduleConfiguration::check_extensions(
id,
"cadence",
Cadence::KNOWN_FIELDS,
&schedule.cadence.extensions,
)?;
if schedule.cadence.extensions.get("anchor").is_some()
|| schedule.cadence.extensions.get("timezone").is_some()
{
return Err(TixError::Config(format!(
"`{path}.cadence` must not carry `anchor` or `timezone`; the schedule's own \
`start_date` and `timezone` are the single copy of each"
)));
}
let Some(kind) = schedule.cadence.kind else {
return Err(TixError::Config(format!(
"`{path}.cadence.kind` is required: name the cadence family \
(`daily`, `weekly`, `monthly`, or `interval` with `interval` and `unit`)"
)));
};
if kind == RuleKind::CronLike {
return Err(TixError::Config(format!(
"`{path}.cadence.kind` (`cron-like`) is not supported for a cycle schedule: the \
bounded cron vocabulary belongs to an issue series rule, and the same cadence \
is spelled `daily`, `weekly` or `monthly` here"
)));
}
let rule = schedule
.cadence
.to_rule(schedule)
.expect("the family was just checked to be present");
if let Err(err) = rule.validate() {
let diagnostic = err.first();
return Err(TixError::Config(format!(
"`{path}.cadence.{}`: {}",
diagnostic.field, diagnostic.message
)));
}
CycleScheduleConfiguration::check_extensions(
id,
"duration",
CycleDuration::KNOWN_FIELDS,
&schedule.duration.extensions,
)?;
if schedule.duration.unit.is_none() {
return Err(TixError::Config(format!(
"`{path}.duration.unit` is required: `days`, `weeks` or `months`"
)));
}
if schedule.duration.count == 0 {
return Err(TixError::Config(format!(
"`{path}.duration.count` is required and must be a positive number of `{}`",
schedule.duration.unit.map_or("units", |unit| unit.as_str())
)));
}
if let (Some(step), Some(duration)) = (schedule.cadence.span(), schedule.duration.span())
&& step.overlaps_next(duration) == Some(true)
{
return Err(TixError::Config(format!(
"`{path}.duration` reaches past `{path}.cadence`, so consecutive cycles would \
overlap; shorten the duration or widen the cadence"
)));
}
if let Some(cursor) = schedule.cursor.as_ref() {
CycleScheduleConfiguration::check_extensions(
id,
"cursor",
crate::cycle_schedule::identity::GenerationCursor::KNOWN_FIELDS,
&cursor.extensions,
)?;
if cursor.next_sequence == Some(0) {
return Err(TixError::Config(format!(
"`{path}.cursor.next_sequence` must be at least 1; a schedule that has not \
generated carries no cursor at all"
)));
}
if cursor.look_ahead_applied_through == Some(0) {
return Err(TixError::Config(format!(
"`{path}.cursor.look_ahead_applied_through` must be at least 1"
)));
}
if let Some(start) = cursor.next_start.as_deref()
&& !crate::model::validate_date(start)
{
return Err(TixError::Config(format!(
"`{path}.cursor.next_start` (`{start}`) must be a `YYYY-MM-DD` calendar date"
)));
}
if let Some(key) = cursor.last_generated_key.as_ref()
&& key.schedule_id() != id
{
return Err(TixError::Config(format!(
"`{path}.cursor.last_generated_key` (`{key}`) belongs to schedule `{}`",
key.schedule_id()
)));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::config::Config;
fn schedule(name: &str) -> CycleScheduleConfiguration {
CycleScheduleConfiguration {
name: name.to_string(),
cadence: Cadence::new(RuleKind::Weekly),
duration: CycleDuration::new(1, IntervalUnit::Weeks),
start_date: "2026-01-05".to_string(),
timezone: Some(Timezone::parse("+01:00")),
look_ahead: 2,
state: ScheduleState::Active,
cursor: None,
extensions: CycleScheduleExtensions::default(),
}
}
fn registry(
entries: impl IntoIterator<Item = (&'static str, CycleScheduleConfiguration)>,
) -> IndexMap<String, CycleScheduleConfiguration> {
entries
.into_iter()
.map(|(id, schedule)| (id.to_string(), schedule))
.collect()
}
fn refusal(schedules: &IndexMap<String, CycleScheduleConfiguration>) -> String {
validate_schedules(schedules)
.expect_err("expected a refusal")
.to_string()
}
#[test]
fn a_valid_schedule_is_accepted_and_round_trips() {
let schedules = registry([("cs-1", schedule("Platform sprint"))]);
validate_schedules(&schedules).expect("a well-formed schedule");
let document = toml::to_string(&schedules).expect("serialize");
let back: IndexMap<String, CycleScheduleConfiguration> =
toml::from_str(&document).expect("deserialize");
assert_eq!(back, schedules);
}
#[test]
fn the_section_is_absent_until_a_schedule_exists() {
let config = Config::default();
assert!(config.cycle_schedules.is_empty());
let rendered = toml::to_string(&config).expect("serialize");
assert!(
!rendered.contains("cycle_schedules"),
"an empty registry must not write a section: {rendered}"
);
}
#[test]
fn a_legacy_named_cycle_registry_is_untouched() {
let source = "[cycles.\"Cycle 1\"]\nstart_date = \"2026-01-01\"\n";
let config: Config = toml::from_str(source).expect("a legacy file loads");
assert!(config.cycles.contains_key("Cycle 1"));
assert!(config.cycle_schedules.is_empty());
let rendered = toml::to_string(&config).expect("serialize");
assert!(rendered.contains("[cycles.\"Cycle 1\"]"));
}
#[test]
fn the_anchor_must_be_a_real_calendar_date() {
let mut broken = schedule("S");
broken.start_date = "2025-02-29".into();
assert!(refusal(®istry([("cs-1", broken)])).contains("start_date"));
}
#[test]
fn the_timezone_is_required_and_never_implicit() {
let mut missing = schedule("S");
missing.timezone = None;
let message = refusal(®istry([("cs-1", missing)]));
assert!(message.contains("timezone"), "{message}");
assert!(message.contains("never used implicitly"), "{message}");
}
#[test]
fn a_named_zone_and_a_malformed_offset_are_told_apart() {
let mut named = schedule("S");
named.timezone = Some(Timezone::parse("Europe/Berlin"));
let message = refusal(®istry([("cs-1", named)]));
assert!(message.contains("named zone"), "{message}");
let mut malformed = schedule("S");
malformed.timezone = Some(Timezone::parse("+1:00"));
let message = refusal(®istry([("cs-1", malformed)]));
assert!(message.contains("not a fixed UTC offset"), "{message}");
}
#[test]
fn an_absent_cadence_family_is_refused_not_defaulted() {
let mut broken = schedule("S");
broken.cadence = Cadence::default();
let message = refusal(®istry([("cs-1", broken)]));
assert!(message.contains("cadence.kind"), "{message}");
}
#[test]
fn the_cron_like_family_is_refused_by_name() {
let mut broken = schedule("S");
broken.cadence = Cadence::new(RuleKind::CronLike);
let message = refusal(®istry([("cs-1", broken)]));
assert!(message.contains("cron-like"), "{message}");
}
#[test]
fn cadence_rule_violations_come_from_the_shared_validator() {
let mut no_unit = schedule("S");
no_unit.cadence = Cadence::new(RuleKind::Interval).with_interval(2);
assert!(refusal(®istry([("cs-1", no_unit)])).contains("unit"));
let mut out_of_range = schedule("S");
out_of_range.cadence = Cadence::new(RuleKind::Daily).with_interval(0);
assert!(refusal(®istry([("cs-1", out_of_range)])).contains("interval"));
let mut unexpected = schedule("S");
unexpected.cadence = Cadence::new(RuleKind::Weekly).with_unit(IntervalUnit::Months);
assert!(refusal(®istry([("cs-1", unexpected)])).contains("unit"));
}
#[test]
fn the_duration_must_be_positive_and_typed() {
let mut zero = schedule("S");
zero.duration = CycleDuration::new(0, IntervalUnit::Weeks);
let message = refusal(®istry([("cs-1", zero)]));
assert!(message.contains("duration.count"), "{message}");
let mut untyped = schedule("S");
untyped.duration = CycleDuration {
count: 2,
unit: None,
..CycleDuration::default()
};
assert!(refusal(®istry([("cs-1", untyped)])).contains("duration.unit"));
}
#[test]
fn a_provably_overlapping_duration_is_refused() {
let mut overlapping = schedule("S");
overlapping.cadence = Cadence::new(RuleKind::Weekly);
overlapping.duration = CycleDuration::new(10, IntervalUnit::Days);
let message = refusal(®istry([("cs-1", overlapping)]));
assert!(message.contains("overlap"), "{message}");
}
#[test]
fn an_undecidable_overlap_is_not_guessed_at() {
let mut cross_family = schedule("S");
cross_family.cadence = Cadence::new(RuleKind::Monthly);
cross_family.duration = CycleDuration::new(2, IntervalUnit::Weeks);
validate_schedules(®istry([("cs-1", cross_family)])).expect("accepted unchanged");
let mut other = schedule("S");
other.cadence = Cadence::new(RuleKind::Interval)
.with_interval(1)
.with_unit(IntervalUnit::Months);
other.duration = CycleDuration::new(90, IntervalUnit::Days);
validate_schedules(®istry([("cs-1", other)])).expect("accepted unchanged");
}
#[test]
fn back_to_back_cycles_are_the_canonical_sprint_not_an_overlap() {
let mut touching = schedule("S");
touching.cadence = Cadence::new(RuleKind::Weekly);
touching.duration = CycleDuration::new(1, IntervalUnit::Weeks);
validate_schedules(®istry([("cs-1", touching)])).expect("adjacent cycles are fine");
let mut longer_step = schedule("S");
longer_step.cadence = Cadence::new(RuleKind::Daily).with_interval(30);
longer_step.duration = CycleDuration::new(1, IntervalUnit::Months);
validate_schedules(®istry([("cs-1", longer_step)])).expect("one month inside 30 days");
}
#[test]
fn a_name_is_required_and_unique_case_folded() {
let mut blank = schedule(" ");
blank.name = " ".into();
assert!(refusal(®istry([("cs-1", blank)])).contains("name"));
let duplicate = registry([
("cs-1", schedule("Platform")),
("cs-2", schedule("platform")),
]);
assert!(refusal(&duplicate).contains("unique"));
}
#[test]
fn the_registry_key_is_a_valid_schedule_id() {
let bad = registry([("cs 1", schedule("S"))]);
assert!(refusal(&bad).contains("cycle schedule id"));
}
#[test]
fn unknown_metadata_inside_each_table_is_preserved_verbatim() {
let mut extended = schedule("S");
extended
.extensions
.insert("future_field", toml::Value::Integer(7));
extended
.cadence
.extensions
.insert("future_cadence", toml::Value::String("kept".into()));
extended
.duration
.extensions
.insert("future_duration", toml::Value::Boolean(true));
let schedules = registry([("cs-1", extended)]);
validate_schedules(&schedules).expect("unknown metadata is inert, not an error");
let document = toml::to_string(&schedules).expect("serialize");
let back: IndexMap<String, CycleScheduleConfiguration> =
toml::from_str(&document).expect("deserialize");
assert_eq!(back, schedules);
assert_eq!(
back["cs-1"].cadence.extensions.get("future_cadence"),
Some(&toml::Value::String("kept".into()))
);
}
#[test]
fn a_preserved_key_may_not_shadow_a_built_in_field() {
let mut shadowing = schedule("S");
shadowing
.extensions
.insert("name", toml::Value::String("other".into()));
let message = refusal(®istry([("cs-1", shadowing)]));
assert!(message.contains("`cycle_schedules.cs-1.name`"), "{message}");
assert!(!message.contains(".."), "a malformed path: {message}");
let mut cadence_shadow = schedule("S");
cadence_shadow
.cadence
.extensions
.insert("interval", toml::Value::Integer(3));
assert!(
refusal(®istry([("cs-1", cadence_shadow)]))
.contains("`cycle_schedules.cs-1.cadence.interval`")
);
let mut duration_shadow = schedule("S");
duration_shadow
.duration
.extensions
.insert("count", toml::Value::Integer(3));
assert!(
refusal(®istry([("cs-1", duration_shadow)]))
.contains("`cycle_schedules.cs-1.duration.count`")
);
}
#[test]
fn the_cadence_may_not_carry_a_second_anchor_or_timezone() {
for key in ["anchor", "timezone"] {
let mut split = schedule("S");
split
.cadence
.extensions
.insert(key, toml::Value::String("elsewhere".into()));
let message = refusal(®istry([("cs-1", split)]));
assert!(message.contains("single copy"), "{key}: {message}");
}
}
#[test]
fn a_schedule_without_look_ahead_means_the_current_cycle_only() {
let mut bare = schedule("S");
bare.look_ahead = 0;
validate_schedules(®istry([("cs-1", bare)])).expect("a coherent instruction");
}
#[test]
fn the_state_defaults_to_active_and_ended_is_terminal() {
let config: CycleScheduleConfiguration =
toml::from_str("name = \"S\"\nstart_date = \"2026-01-01\"\ntimezone = \"UTC\"\n")
.expect("loads");
assert_eq!(config.state, ScheduleState::Active);
assert!(!config.state.is_terminal());
assert!(ScheduleState::Ended.is_terminal());
assert_eq!(ScheduleState::Ended.as_str(), "ended");
}
#[test]
fn the_cadence_anchors_on_the_schedule_not_on_itself() {
let schedule = schedule("S");
let rule = schedule.cadence.to_rule(&schedule).expect("a rule");
assert_eq!(rule.anchor.as_deref(), Some("2026-01-05"));
assert_eq!(rule.timezone, schedule.timezone);
assert!(rule.expression.is_none());
}
#[test]
fn a_generated_id_is_valid_under_the_shared_rules() {
let id = CycleScheduleId::generate();
assert!(id.as_str().starts_with("cs-"));
CycleScheduleId::new(id.as_str()).expect("a generated id validates");
}
}