use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use time::Date;
use crate::config::Config;
use crate::cycle_schedule::config::{CycleScheduleConfiguration, CycleScheduleId, ScheduleState};
use crate::cycle_schedule::identity::{
CycleReference, GeneratedCycle, GeneratedCycleId, GeneratedCycleState, GenerationCursor,
NextCycleKey, ScheduleIndex, cadence_rule,
};
use crate::cycle_store::{CycleRecord, CycleStore};
use crate::error::{Result, render_diagnostics};
use crate::recurrence::rule::{RecurrenceRule, RuleContext, RuleKind};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GenerationRequest {
pub schedule_id: CycleScheduleId,
pub context: RuleContext,
pub look_ahead: u32,
pub actor: String,
pub confirmed: bool,
}
impl GenerationRequest {
pub fn new(
schedule_id: CycleScheduleId,
context: RuleContext,
look_ahead: u32,
actor: impl Into<String>,
) -> Self {
Self {
schedule_id,
context,
look_ahead,
actor: actor.into(),
confirmed: false,
}
}
#[must_use]
pub fn confirm(mut self) -> Self {
self.confirmed = true;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GenerationDiagnosticKind {
NotConfirmed,
UnknownSchedule,
ScheduleNotActive,
MissingCadence,
InvalidCadence,
InvalidScheduleName,
InvalidDuration,
NoCandidate,
UnresolvableDate,
InvalidContext,
ReferenceCollision,
NameCollision,
SchedulingFailed,
CursorNotPersisted,
}
impl GenerationDiagnosticKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::NotConfirmed => "not_confirmed",
Self::UnknownSchedule => "unknown_schedule",
Self::ScheduleNotActive => "schedule_not_active",
Self::MissingCadence => "missing_cadence",
Self::InvalidCadence => "invalid_cadence",
Self::InvalidScheduleName => "invalid_schedule_name",
Self::InvalidDuration => "invalid_duration",
Self::NoCandidate => "no_candidate",
Self::UnresolvableDate => "unresolvable_date",
Self::InvalidContext => "invalid_context",
Self::ReferenceCollision => "reference_collision",
Self::NameCollision => "name_collision",
Self::SchedulingFailed => "scheduling_failure",
Self::CursorNotPersisted => "cursor_not_persisted",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GenerationDiagnostic {
pub kind: GenerationDiagnosticKind,
pub field: String,
pub message: String,
}
impl GenerationDiagnostic {
fn new(
kind: GenerationDiagnosticKind,
field: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
kind,
field: field.into(),
message: message.into(),
}
}
#[must_use]
pub fn render(&self) -> String {
render_diagnostics(
"E-tix-cycle-schedule",
std::iter::once((self.kind.as_str(), self.message.as_str())),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GenerationOutcome {
Created {
schedule_id: CycleScheduleId,
key: GeneratedCycleId,
cycle: GeneratedCycle,
},
NoOp {
schedule_id: CycleScheduleId,
key: GeneratedCycleId,
state: GeneratedCycleState,
message: String,
},
Rejected {
schedule_id: CycleScheduleId,
key: Option<GeneratedCycleId>,
diagnostics: Vec<GenerationDiagnostic>,
},
}
impl GenerationOutcome {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Created { .. } => "created",
Self::NoOp { .. } => "no-op",
Self::Rejected { .. } => "rejected",
}
}
#[must_use]
pub fn is_created(&self) -> bool {
matches!(self, Self::Created { .. })
}
#[must_use]
pub fn is_noop(&self) -> bool {
matches!(self, Self::NoOp { .. })
}
#[must_use]
pub fn is_rejected(&self) -> bool {
matches!(self, Self::Rejected { .. })
}
#[must_use]
pub fn schedule_id(&self) -> &CycleScheduleId {
match self {
Self::Created { schedule_id, .. }
| Self::NoOp { schedule_id, .. }
| Self::Rejected { schedule_id, .. } => schedule_id,
}
}
#[must_use]
pub fn key(&self) -> Option<&GeneratedCycleId> {
match self {
Self::Created { key, .. } | Self::NoOp { key, .. } => Some(key),
Self::Rejected { key, .. } => key.as_ref(),
}
}
#[must_use]
pub fn cycle(&self) -> Option<&GeneratedCycle> {
match self {
Self::Created { cycle, .. } => Some(cycle),
_ => None,
}
}
#[must_use]
pub fn state(&self) -> Option<GeneratedCycleState> {
match self {
Self::Created { cycle, .. } => Some(cycle.state),
Self::NoOp { state, .. } => Some(*state),
Self::Rejected { .. } => None,
}
}
#[must_use]
pub fn diagnostics(&self) -> &[GenerationDiagnostic] {
match self {
Self::Rejected { diagnostics, .. } => diagnostics,
_ => &[],
}
}
#[must_use]
pub fn render(&self) -> String {
match self {
Self::Created { key, cycle, .. } => format!(
"created `{key}` (`{}`, {} → {})",
cycle.name, cycle.start_date, cycle.due_date
),
Self::NoOp { key, message, .. } => format!("no-op for `{key}`: {message}"),
Self::Rejected {
key, diagnostics, ..
} => {
let first = diagnostics
.first()
.map(GenerationDiagnostic::render)
.unwrap_or_else(|| {
format!(
"`{}` rejected",
key.as_ref().map_or("request", GeneratedCycleId::as_str)
)
});
if diagnostics.len() > 1 {
format!("{first} (and {} more)", diagnostics.len() - 1)
} else {
first
}
}
}
}
fn refused(
schedule_id: CycleScheduleId,
key: Option<GeneratedCycleId>,
diagnostic: GenerationDiagnostic,
) -> Self {
Self::Rejected {
schedule_id,
key,
diagnostics: vec![diagnostic],
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct GenerationReport {
actor: String,
as_of: Date,
schedule_id: CycleScheduleId,
outcomes: Vec<GenerationOutcome>,
cursor: Option<GenerationCursor>,
}
impl GenerationReport {
#[must_use]
pub fn actor(&self) -> &str {
&self.actor
}
#[must_use]
pub fn as_of(&self) -> Date {
self.as_of
}
#[must_use]
pub fn schedule_id(&self) -> &CycleScheduleId {
&self.schedule_id
}
#[must_use]
pub fn outcomes(&self) -> &[GenerationOutcome] {
&self.outcomes
}
#[must_use]
pub fn len(&self) -> usize {
self.outcomes.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.outcomes.is_empty()
}
#[must_use]
pub fn is_refused(&self) -> bool {
!self.outcomes.is_empty() && self.outcomes.iter().all(GenerationOutcome::is_rejected)
}
#[must_use]
pub fn cursor(&self) -> Option<&GenerationCursor> {
self.cursor.as_ref()
}
pub fn created(&self) -> impl Iterator<Item = &GenerationOutcome> {
self.outcomes.iter().filter(|outcome| outcome.is_created())
}
pub fn no_ops(&self) -> impl Iterator<Item = &GenerationOutcome> {
self.outcomes.iter().filter(|outcome| outcome.is_noop())
}
pub fn rejected(&self) -> impl Iterator<Item = &GenerationOutcome> {
self.outcomes.iter().filter(|outcome| outcome.is_rejected())
}
#[must_use]
pub fn render(&self) -> String {
if self.outcomes.is_empty() {
return format!(
"{}: nothing to generate for `{}` (no look-ahead requested)",
self.actor, self.schedule_id
);
}
format!(
"{}: {} created, {} no-op, {} rejected for `{}`",
self.actor,
self.created().count(),
self.no_ops().count(),
self.rejected().count(),
self.schedule_id
)
}
fn refused(
as_of: Date,
actor: &str,
schedule_id: CycleScheduleId,
diagnostic: GenerationDiagnostic,
) -> Self {
Self::refused_many(as_of, actor, schedule_id, vec![diagnostic])
}
fn refused_many(
as_of: Date,
actor: &str,
schedule_id: CycleScheduleId,
diagnostics: Vec<GenerationDiagnostic>,
) -> Self {
Self {
actor: actor.to_string(),
as_of,
outcomes: vec![GenerationOutcome::Rejected {
schedule_id: schedule_id.clone(),
key: None,
diagnostics,
}],
schedule_id,
cursor: None,
}
}
}
#[allow(clippy::too_many_lines)]
pub fn generate(
config: &mut Config,
root: &Path,
index: &ScheduleIndex,
request: &GenerationRequest,
) -> Result<GenerationReport> {
let as_of = request.context.today;
let schedule_id = request.schedule_id.clone();
if !request.confirmed {
return Ok(GenerationReport::refused(
as_of,
&request.actor,
schedule_id.clone(),
GenerationDiagnostic::new(
GenerationDiagnosticKind::NotConfirmed,
format!("cycle_schedules.{schedule_id}"),
"generation must be confirmed: detection reports a candidate, a person or a \
caller decides, and only then is anything written",
),
));
}
if let Some(timezone) = request.context.timezone.as_ref()
&& !timezone.is_supported()
{
return Ok(GenerationReport::refused(
as_of,
&request.actor,
schedule_id.clone(),
GenerationDiagnostic::new(
GenerationDiagnosticKind::InvalidContext,
format!("cycle_schedules.{schedule_id}.timezone"),
format!(
"the injected date context carries `{timezone}`, which is not a fixed UTC \
offset; generation will not normalize an ambiguous zone"
),
),
));
}
let Some(record) = index.schedule(&schedule_id) else {
return Ok(GenerationReport::refused(
as_of,
&request.actor,
schedule_id.clone(),
GenerationDiagnostic::new(
GenerationDiagnosticKind::UnknownSchedule,
format!("cycle_schedules.{schedule_id}"),
format!("no cycle schedule `{schedule_id}` is defined"),
),
));
};
if record.state != ScheduleState::Active {
let fix = if record.state.is_terminal() {
"an ended schedule is terminal; generation is refused for good"
} else {
"resume it explicitly before generating"
};
return Ok(GenerationReport::refused(
as_of,
&request.actor,
schedule_id.clone(),
GenerationDiagnostic::new(
GenerationDiagnosticKind::ScheduleNotActive,
format!("cycle_schedules.{schedule_id}.state"),
format!(
"schedule `{schedule_id}` is {}; {fix}",
record.state.as_str()
),
),
));
}
let Some(rule) = cadence_rule(record) else {
return Ok(GenerationReport::refused(
as_of,
&request.actor,
schedule_id.clone(),
GenerationDiagnostic::new(
GenerationDiagnosticKind::MissingCadence,
format!("cycle_schedules.{schedule_id}.cadence.kind"),
format!(
"schedule `{schedule_id}` names no cadence family, so no candidate can be \
derived"
),
),
));
};
if let Err(error) = rule.validate() {
let diagnostics = error
.diagnostics()
.iter()
.map(|diagnostic| {
GenerationDiagnostic::new(
GenerationDiagnosticKind::InvalidCadence,
format!(
"cycle_schedules.{schedule_id}.{}",
cadence_field(diagnostic.field)
),
diagnostic.message.clone(),
)
})
.collect();
return Ok(GenerationReport::refused_many(
as_of,
&request.actor,
schedule_id.clone(),
diagnostics,
));
}
if record.name.trim().is_empty() {
return Ok(GenerationReport::refused(
as_of,
&request.actor,
schedule_id.clone(),
GenerationDiagnostic::new(
GenerationDiagnosticKind::InvalidScheduleName,
format!("cycle_schedules.{schedule_id}.name"),
format!(
"schedule `{schedule_id}` has a blank display name, so no reference label can \
be derived for an instance"
),
),
));
}
let store = CycleStore::new(root);
let config_path = root.join("tix.toml");
let durable = ScheduleIndex::build(config, &store.list()?);
let mut files = stored_files(&store)?;
let Some(durable_next) = durable.next_key(&schedule_id) else {
return Ok(GenerationReport::refused(
as_of,
&request.actor,
schedule_id.clone(),
GenerationDiagnostic::new(
GenerationDiagnosticKind::NoCandidate,
format!("cycle_schedules.{schedule_id}.cursor"),
format!(
"schedule `{schedule_id}` has no next candidate: its generation position is \
past the last sequence the identity's fixed width can hold"
),
),
));
};
let cursor_before = durable.cursor(&schedule_id);
let missing_dirs = missing_ancestors(store.dir());
let first = match index.next_key(&schedule_id) {
Some(caller_next) if caller_next.sequence < durable_next.sequence => caller_next,
_ => durable_next,
};
let keys = candidate_keys(&rule, &first, request.look_ahead);
let mut outcomes: Vec<GenerationOutcome> = Vec::new();
let mut created: Vec<usize> = Vec::new();
for key in keys {
let id = key.id.clone();
if let Some(existing) = durable.cycle(&id) {
outcomes.push(GenerationOutcome::NoOp {
schedule_id: schedule_id.clone(),
key: id,
state: existing.state,
message: format!(
"`{}` already holds it (`{}`); the stable key is idempotent and nothing was \
written",
existing.name,
existing.state.as_str()
),
});
continue;
}
let Some(start_date) = key.start_date.clone() else {
outcomes.push(GenerationOutcome::refused(
schedule_id.clone(),
Some(id.clone()),
GenerationDiagnostic::new(
GenerationDiagnosticKind::UnresolvableDate,
format!("cycle_schedules.{schedule_id}.cadence"),
format!(
"candidate `{id}` has no resolvable start date: the schedule's cadence \
produced none from the instance it steps from"
),
),
));
break;
};
let name = reference_name(&record.name, &start_date, key.sequence);
if !matches!(durable.resolve(&name), CycleReference::Unknown { .. }) {
outcomes.push(GenerationOutcome::refused(
schedule_id.clone(),
Some(id.clone()),
GenerationDiagnostic::new(
GenerationDiagnosticKind::ReferenceCollision,
format!("cycles.{name}"),
format!(
"the reference label `{name}` an issue would hold already answers to \
another cycle; rename that cycle or the schedule before generating"
),
),
));
break;
}
let filename = instance_filename(&name);
let occupant = files.get(&filename);
let path_taken = occupant.is_some() || store.dir().join(&filename).exists();
if path_taken {
let holds_this_key = occupant
.and_then(|record| GeneratedCycle::from_record(record).ok().flatten())
.filter(|existing| existing.id == id);
if let Some(existing) = holds_this_key {
outcomes.push(GenerationOutcome::NoOp {
schedule_id: schedule_id.clone(),
key: id,
state: existing.state,
message: format!(
"`docs/cycles/{filename}` already holds this key (`{}`); nothing was \
written",
existing.state.as_str()
),
});
continue;
}
let other = match occupant {
Some(record) => match GeneratedCycle::from_record(record).ok().flatten() {
Some(existing) => format!("`{}`", existing.id),
None => "a hand-written cycle".to_string(),
},
None => format!(
"a file `docs/cycles/{filename}` this build cannot read as a cycle record"
),
};
outcomes.push(GenerationOutcome::refused(
schedule_id.clone(),
Some(id.clone()),
GenerationDiagnostic::new(
GenerationDiagnosticKind::NameCollision,
format!("docs/cycles/{filename}"),
format!(
"`{id}` and {other} would be written to the same file \
`docs/cycles/{filename}`; that can only lose one of them, so \
nothing was written — move the file that is already there"
),
),
));
break;
}
let due_date = match due_date(&schedule_id, record, &start_date) {
Ok(due_date) => due_date,
Err(diagnostic) => {
outcomes.push(GenerationOutcome::refused(
schedule_id.clone(),
Some(id.clone()),
diagnostic,
));
break;
}
};
let cycle = GeneratedCycle {
id: id.clone(),
schedule_id: schedule_id.clone(),
sequence: key.sequence,
name,
start_date,
due_date,
state: GeneratedCycleState::Active,
};
match store.save_record(&cycle.to_record(), None) {
Ok(()) => {
created.push(outcomes.len());
files.insert(filename, cycle.to_record());
outcomes.push(GenerationOutcome::Created {
schedule_id: schedule_id.clone(),
key: id,
cycle,
});
}
Err(error) => {
outcomes.push(GenerationOutcome::refused(
schedule_id.clone(),
Some(id.clone()),
GenerationDiagnostic::new(
GenerationDiagnosticKind::SchedulingFailed,
format!("cycles.{id}"),
format!(
"the instance record for `{id}` could not be written: {error}; the \
cursor was not advanced and nothing else was attempted"
),
),
));
break;
}
}
}
let cursor = if created.is_empty() {
cursor_before
} else {
let after = ScheduleIndex::build(config, &store.list()?);
let cursor = after.cursor(&schedule_id);
let previous = config
.cycle_schedules
.get(schedule_id.as_str())
.and_then(|record| record.cursor.clone());
if let Some(record) = config.cycle_schedules.get_mut(schedule_id.as_str()) {
record.cursor = Some(cursor.clone());
}
match config.save(&config_path) {
Ok(()) => cursor,
Err(error) => {
if let Some(record) = config.cycle_schedules.get_mut(schedule_id.as_str()) {
record.cursor = previous;
}
let keys: Vec<String> = created
.iter()
.filter_map(|index| outcomes[*index].key().map(GeneratedCycleId::to_string))
.collect();
let rollback = roll_back(&store, &outcomes, &created, &missing_dirs);
let message = match rollback {
None => format!(
"the generation cursor for `{schedule_id}` could not be persisted through \
`{}`: {error}; {} was rolled back, so the project is exactly as it was \
and a retry starts from the same key",
config_path.display(),
keys.join(", ")
),
Some(failures) => format!(
"the generation cursor for `{schedule_id}` could not be persisted through \
`{}`: {error}; rolling back {} also failed ({failures}), so those \
instances remain and a re-run will report them as a no-op while the \
index reports `cursor_drift`",
config_path.display(),
keys.join(", ")
),
};
for index in &created {
outcomes[*index] = GenerationOutcome::refused(
schedule_id.clone(),
None,
GenerationDiagnostic::new(
GenerationDiagnosticKind::CursorNotPersisted,
format!("cycle_schedules.{schedule_id}.cursor"),
message.clone(),
),
);
}
cursor_before
}
}
};
Ok(GenerationReport {
actor: request.actor.clone(),
as_of,
schedule_id,
outcomes,
cursor: Some(cursor),
})
}
const FILENAME_STEM_LIMIT: usize = 80;
const CANONICAL_DATE_LEN: usize = 10;
const NAME_COMPONENT_BUDGET: usize =
FILENAME_STEM_LIMIT - 1 - CANONICAL_DATE_LEN - 1 - GeneratedCycleId::SEQUENCE_WIDTH;
fn reference_name(schedule_name: &str, start_date: &str, sequence: u32) -> String {
let component: String = schedule_name
.trim()
.chars()
.take(NAME_COMPONENT_BUDGET)
.collect();
let component = component.trim_end();
format!(
"{component} {start_date} {sequence:0width$}",
width = GeneratedCycleId::SEQUENCE_WIDTH
)
}
fn instance_filename(name: &str) -> String {
format!("{}.md", crate::slug::slugify(name))
}
fn stored_files(store: &CycleStore) -> Result<BTreeMap<String, CycleRecord>> {
Ok(crate::records::RecordStore::new(store.dir())
.list::<CycleRecord>()?
.into_iter()
.map(|record| (record.filename, record.value))
.collect())
}
fn candidate_keys(
rule: &RecurrenceRule,
first: &NextCycleKey,
look_ahead: u32,
) -> Vec<NextCycleKey> {
let mut keys = Vec::new();
let mut date = first.start_date.clone();
for sequence in (first.sequence..).take(look_ahead as usize) {
if sequence >= GeneratedCycleId::SEQUENCE_LIMIT {
break;
}
keys.push(NextCycleKey {
schedule_id: first.schedule_id.clone(),
sequence,
id: GeneratedCycleId::new(&first.schedule_id, sequence),
start_date: date.clone(),
});
let Some(start) = date else {
break;
};
date = next_start(rule, &start);
}
keys
}
fn next_start(rule: &RecurrenceRule, from: &str) -> Option<String> {
let date = as_date(from)?;
rule.next_occurrence(&RuleContext::on(date))
.ok()
.map(|key| key.as_str().to_string())
}
fn due_date(
schedule_id: &CycleScheduleId,
record: &CycleScheduleConfiguration,
start_date: &str,
) -> std::result::Result<String, GenerationDiagnostic> {
let field = format!("cycle_schedules.{schedule_id}.duration");
let Some(unit) = record.duration.unit else {
return Err(GenerationDiagnostic::new(
GenerationDiagnosticKind::InvalidDuration,
format!("{field}.unit"),
"duration.unit: how long one cycle lasts is not configured; give `days`, `weeks` or \
`months`",
));
};
if record.duration.count == 0 {
return Err(GenerationDiagnostic::new(
GenerationDiagnosticKind::InvalidDuration,
format!("{field}.count"),
"duration.count: a cycle must last at least one unit",
));
}
let Some(start) = as_date(start_date) else {
return Err(GenerationDiagnostic::new(
GenerationDiagnosticKind::UnresolvableDate,
format!("{field}.count"),
format!(
"the candidate's start date `{start_date}` is not a `YYYY-MM-DD` calendar date, \
so its duration cannot be added"
),
));
};
let rule = RecurrenceRule {
kind: RuleKind::Interval,
interval: Some(record.duration.count),
anchor: Some(start_date.to_string()),
timezone: record.timezone.clone(),
expression: None,
unit: Some(unit),
extensions: BTreeMap::new(),
};
rule.next_occurrence(&RuleContext::on(start))
.map(|due| due.as_str().to_string())
.map_err(|error| {
let first = error.first();
GenerationDiagnostic::new(
GenerationDiagnosticKind::InvalidDuration,
format!("{field}.{}", duration_field(first.field)),
first.message.clone(),
)
})
}
fn cadence_field(field: &str) -> String {
match field {
"anchor" => "start_date".to_string(),
"timezone" => "timezone".to_string(),
other => format!("cadence.{other}"),
}
}
fn duration_field(field: &str) -> String {
match field {
"interval" => "count".to_string(),
"anchor" => "start_date".to_string(),
"timezone" => "timezone".to_string(),
other => other.to_string(),
}
}
fn as_date(value: &str) -> Option<Date> {
if !crate::model::validate_date(value) {
return None;
}
let year: i32 = value.get(0..4)?.parse().ok()?;
let month: u8 = value.get(5..7)?.parse().ok()?;
let day: u8 = value.get(8..10)?.parse().ok()?;
Date::from_calendar_date(year, time::Month::try_from(month).ok()?, day).ok()
}
fn missing_ancestors(dir: &Path) -> Vec<PathBuf> {
let mut missing = Vec::new();
let mut current = Some(dir.to_path_buf());
while let Some(path) = current {
if path.exists() {
break;
}
missing.push(path.clone());
current = path.parent().map(Path::to_path_buf);
}
missing
}
fn roll_back(
store: &CycleStore,
outcomes: &[GenerationOutcome],
created: &[usize],
missing_dirs: &[PathBuf],
) -> Option<String> {
let mut failures = Vec::new();
for index in created {
let Some(cycle) = outcomes[*index].cycle() else {
continue;
};
if let Err(error) = store.delete(&cycle.name) {
failures.push(format!("`{}`: {error}", cycle.name));
}
}
for dir in missing_dirs {
let empty = std::fs::read_dir(dir)
.map(|mut entries| entries.next().is_none())
.unwrap_or(false);
if empty {
let _ = std::fs::remove_dir(dir);
}
}
if failures.is_empty() {
None
} else {
Some(failures.join("; "))
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::cycle_schedule::config::{Cadence, CycleDuration, CycleScheduleExtensions};
use crate::recurrence::rule::{IntervalUnit, RuleDiagnostic, RuleDiagnosticKind, Timezone};
fn schedule_id() -> CycleScheduleId {
CycleScheduleId::new("cs-weekly").unwrap()
}
fn schedule(duration: CycleDuration) -> CycleScheduleConfiguration {
CycleScheduleConfiguration {
name: "Weekly sprint".to_string(),
cadence: Cadence::new(RuleKind::Weekly),
duration,
start_date: "2026-01-05".to_string(),
timezone: Some(Timezone::parse("UTC")),
look_ahead: 2,
state: ScheduleState::Active,
cursor: None,
extensions: CycleScheduleExtensions::default(),
}
}
#[test]
fn a_request_is_unconfirmed_until_it_says_so() {
let context =
RuleContext::on(Date::from_calendar_date(2026, time::Month::January, 5).unwrap());
let request = GenerationRequest::new(schedule_id(), context.clone(), 2, "alice");
assert!(!request.confirmed, "naming a schedule is not consent");
let confirmed = request.clone().confirm();
assert!(confirmed.confirmed);
assert_eq!(confirmed.actor, "alice");
assert_eq!(confirmed.look_ahead, 2);
assert_eq!(confirmed.context, context);
}
#[test]
fn the_reference_label_keeps_its_discriminating_suffix_through_any_truncation() {
assert_eq!(
reference_name("Weekly sprint", "2026-01-05", 1),
"Weekly sprint 2026-01-05 000001"
);
assert!(
reference_name("Weekly sprint", "2026-01-05", 1)
< reference_name("Weekly sprint", "2026-01-12", 2),
"a canonical date keeps the labels in chronological order"
);
let long = "x".repeat(500);
let first = reference_name(&long, "2026-01-05", 1);
let second = reference_name(&long, "2026-01-12", 2);
assert_eq!(crate::slug::slugify(&first).len(), FILENAME_STEM_LIMIT);
assert_ne!(crate::slug::slugify(&first), crate::slug::slugify(&second));
assert!(instance_filename(&first).ends_with("-2026-01-05-000001.md"));
assert!(instance_filename(&second).ends_with("-2026-01-12-000002.md"));
assert_eq!(
instance_filename(&first),
instance_filename(&first),
"the path a name becomes is deterministic"
);
for sequence in [1, 999_999] {
let name = reference_name(&long, "2026-01-05", sequence);
assert!(crate::slug::slugify(&name).len() <= FILENAME_STEM_LIMIT);
assert!(instance_filename(&name).contains(&format!("{sequence:06}")));
}
}
#[test]
fn every_outcome_has_one_stable_word() {
let id = GeneratedCycleId::new(&schedule_id(), 1);
let created = GenerationOutcome::Created {
schedule_id: schedule_id(),
key: id.clone(),
cycle: GeneratedCycle {
id: id.clone(),
schedule_id: schedule_id(),
sequence: 1,
name: "Weekly sprint 2026-01-05".to_string(),
start_date: "2026-01-05".to_string(),
due_date: "2026-01-12".to_string(),
state: GeneratedCycleState::Active,
},
};
assert_eq!(created.as_str(), "created");
assert!(created.is_created() && created.cycle().is_some());
assert_eq!(created.key(), Some(&id));
assert!(created.render().contains("2026-01-12"));
let noop = GenerationOutcome::NoOp {
schedule_id: schedule_id(),
key: id.clone(),
state: GeneratedCycleState::Skipped,
message: "already holds it".to_string(),
};
assert_eq!(noop.as_str(), "no-op");
assert!(noop.is_noop());
assert_eq!(noop.state(), Some(GeneratedCycleState::Skipped));
let refused = GenerationOutcome::refused(
schedule_id(),
Some(id.clone()),
GenerationDiagnostic::new(
GenerationDiagnosticKind::ReferenceCollision,
"cycles.x",
"already taken",
),
);
assert_eq!(refused.as_str(), "rejected");
assert!(refused.is_rejected());
assert!(refused.render().contains("reference_collision"));
}
#[test]
fn the_diagnostic_kinds_are_the_closed_vocabulary() {
let spellings: Vec<&str> = [
GenerationDiagnosticKind::NotConfirmed,
GenerationDiagnosticKind::UnknownSchedule,
GenerationDiagnosticKind::ScheduleNotActive,
GenerationDiagnosticKind::MissingCadence,
GenerationDiagnosticKind::InvalidCadence,
GenerationDiagnosticKind::InvalidScheduleName,
GenerationDiagnosticKind::InvalidDuration,
GenerationDiagnosticKind::NoCandidate,
GenerationDiagnosticKind::UnresolvableDate,
GenerationDiagnosticKind::InvalidContext,
GenerationDiagnosticKind::ReferenceCollision,
GenerationDiagnosticKind::NameCollision,
GenerationDiagnosticKind::SchedulingFailed,
GenerationDiagnosticKind::CursorNotPersisted,
]
.iter()
.map(|kind| kind.as_str())
.collect();
assert_eq!(
spellings,
vec![
"not_confirmed",
"unknown_schedule",
"schedule_not_active",
"missing_cadence",
"invalid_cadence",
"invalid_schedule_name",
"invalid_duration",
"no_candidate",
"unresolvable_date",
"invalid_context",
"reference_collision",
"name_collision",
"scheduling_failure",
"cursor_not_persisted",
]
);
}
#[test]
fn a_due_date_is_the_shared_owners_step_including_month_end_and_leap_days() {
let monthly = schedule(CycleDuration::new(1, IntervalUnit::Months));
assert_eq!(
due_date(&schedule_id(), &monthly, "2026-01-31").unwrap(),
"2026-02-28"
);
assert_eq!(
due_date(&schedule_id(), &monthly, "2024-02-29").unwrap(),
"2024-03-29"
);
let fortnight = schedule(CycleDuration::new(2, IntervalUnit::Weeks));
assert_eq!(
due_date(&schedule_id(), &fortnight, "2026-01-05").unwrap(),
"2026-01-19"
);
}
#[test]
fn a_duration_the_shared_owner_refuses_is_a_field_aware_refusal_at_the_users_field() {
let huge = schedule(CycleDuration::new(1001, IntervalUnit::Weeks));
let error = due_date(&schedule_id(), &huge, "2026-01-05").unwrap_err();
assert_eq!(error.kind, GenerationDiagnosticKind::InvalidDuration);
assert_eq!(error.field, "cycle_schedules.cs-weekly.duration.count");
}
#[test]
fn a_missing_duration_unit_and_a_zero_count_are_named_where_they_are_written() {
let broken = schedule(CycleDuration {
count: 1,
unit: None,
extensions: CycleScheduleExtensions::default(),
});
let error = due_date(&schedule_id(), &broken, "2026-01-05").unwrap_err();
assert_eq!(error.field, "cycle_schedules.cs-weekly.duration.unit");
let zero = schedule(CycleDuration {
count: 0,
unit: Some(IntervalUnit::Weeks),
extensions: CycleScheduleExtensions::default(),
});
let error = due_date(&schedule_id(), &zero, "2026-01-05").unwrap_err();
assert_eq!(error.field, "cycle_schedules.cs-weekly.duration.count");
}
#[test]
fn rule_fields_are_translated_to_the_configuration_fields_they_came_from() {
assert_eq!(cadence_field("anchor"), "start_date");
assert_eq!(cadence_field("interval"), "cadence.interval");
assert_eq!(duration_field("interval"), "count");
assert_eq!(duration_field("unit"), "unit");
let diagnostic = RuleDiagnostic {
kind: RuleDiagnosticKind::InvalidAnchor,
field: "anchor",
value: "2026-02-30".to_string(),
message: "anchor: not a real date".to_string(),
};
assert_eq!(
cadence_field(diagnostic.field),
"start_date",
"a cadence anchor is the schedule's start_date"
);
}
#[test]
fn the_candidate_batch_is_the_next_key_then_shared_rule_steps() {
let record = schedule(CycleDuration::new(1, IntervalUnit::Weeks));
let rule = cadence_rule(&record).unwrap();
let first = NextCycleKey {
schedule_id: schedule_id(),
sequence: 1,
id: GeneratedCycleId::new(&schedule_id(), 1),
start_date: Some("2026-01-05".to_string()),
};
let keys = candidate_keys(&rule, &first, 3);
let dates: Vec<Option<String>> = keys.iter().map(|key| key.start_date.clone()).collect();
assert_eq!(
dates,
vec![
Some("2026-01-05".to_string()),
Some("2026-01-12".to_string()),
Some("2026-01-19".to_string()),
]
);
assert_eq!(keys[2].id.as_str(), "cs-weekly:000003");
assert_eq!(
candidate_keys(&rule, &first, 0),
[] as [crate::cycle_schedule::identity::NextCycleKey; 0]
);
}
#[test]
fn missing_ancestors_stops_at_the_first_directory_that_exists() {
let missing = super::missing_ancestors(Path::new("/definitely/not/a/real/path/cycles"));
assert_eq!(
missing.first().map(PathBuf::as_path),
Some(Path::new("/definitely/not/a/real/path/cycles"))
);
assert!(missing.len() > 1, "every missing ancestor is listed");
assert!(
missing.len() < 8,
"the walk stops at the first existing directory"
);
}
}