use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use time::{Date, Month};
use crate::config::Config;
use crate::cycle_schedule::config::{
CycleScheduleConfiguration, CycleScheduleExtensions, CycleScheduleId,
};
use crate::cycle_store::CycleRecord;
use crate::error::{Result, TixError, render_diagnostics};
use crate::recurrence::rule::{RecurrenceRule, RuleContext};
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct GeneratedCycleId(String);
impl GeneratedCycleId {
pub const SEQUENCE_WIDTH: usize = 6;
pub const SEQUENCE_LIMIT: u32 = 1_000_000;
#[must_use]
pub fn new(schedule: &CycleScheduleId, sequence: u32) -> Self {
debug_assert!(sequence > 0, "sequences start at 1");
Self(format!(
"{}:{:0width$}",
schedule.as_str(),
sequence,
width = Self::SEQUENCE_WIDTH
))
}
pub fn parse(raw: &str) -> Result<Self> {
let (schedule, sequence) = raw.rsplit_once(':').ok_or_else(|| {
TixError::Config(format!(
"generated cycle id `{raw}` must be `<schedule-id>:<sequence>`"
))
})?;
CycleScheduleId::new(schedule)
.map_err(|err| TixError::Config(format!("generated cycle id `{raw}`: {err}")))?;
if sequence.len() != Self::SEQUENCE_WIDTH || !sequence.bytes().all(|b| b.is_ascii_digit()) {
return Err(TixError::Config(format!(
"generated cycle id `{raw}` must carry a {}-digit sequence",
Self::SEQUENCE_WIDTH
)));
}
let parsed: u32 = sequence.parse().map_err(|_| {
TixError::Config(format!(
"generated cycle id `{raw}` has an unreadable sequence"
))
})?;
if parsed == 0 {
return Err(TixError::Config(format!(
"generated cycle id `{raw}` has sequence 0; sequences start at 1"
)));
}
Ok(Self(raw.to_string()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn schedule_id(&self) -> &str {
self.0
.rsplit_once(':')
.map_or(self.0.as_str(), |(schedule, _)| schedule)
}
#[must_use]
pub fn sequence(&self) -> u32 {
self.0
.rsplit_once(':')
.and_then(|(_, sequence)| sequence.parse().ok())
.unwrap_or(0)
}
#[must_use]
pub fn belongs_to(&self, schedule: &CycleScheduleId) -> bool {
self.schedule_id() == schedule.as_str()
}
}
impl fmt::Display for GeneratedCycleId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for GeneratedCycleId {
type Error = TixError;
fn try_from(raw: String) -> Result<Self> {
Self::parse(&raw)
}
}
impl From<GeneratedCycleId> for String {
fn from(id: GeneratedCycleId) -> Self {
id.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum GeneratedCycleState {
#[default]
Active,
Closed,
Skipped,
Paused,
}
impl GeneratedCycleState {
pub const fn as_str(self) -> &'static str {
match self {
GeneratedCycleState::Active => "active",
GeneratedCycleState::Closed => "closed",
GeneratedCycleState::Skipped => "skipped",
GeneratedCycleState::Paused => "paused",
}
}
pub fn parse(raw: &str) -> Result<Self> {
match raw {
"active" => Ok(Self::Active),
"closed" => Ok(Self::Closed),
"skipped" => Ok(Self::Skipped),
"paused" => Ok(Self::Paused),
other => Err(TixError::Config(format!(
"generated cycle state `{other}` is not one of `active`, `closed`, `skipped`, `paused`"
))),
}
}
pub const fn is_terminal(self) -> bool {
matches!(self, Self::Closed | Self::Skipped)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeneratedCycle {
pub id: GeneratedCycleId,
pub schedule_id: CycleScheduleId,
pub sequence: u32,
pub name: String,
pub start_date: String,
pub due_date: String,
pub state: GeneratedCycleState,
}
impl GeneratedCycle {
pub fn from_record(record: &CycleRecord) -> Result<Option<Self>> {
let (Some(schedule_raw), Some(sequence), Some(state_raw)) = (
record.schedule_id.as_deref(),
record.sequence,
record.state.as_deref(),
) else {
if record.schedule_id.is_none() && record.sequence.is_none() && record.state.is_none() {
return Ok(None);
}
return Err(TixError::Config(format!(
"cycle `{}` carries a partial generated identity: `schedule_id`, `sequence` \
and `state` are written together or not at all",
record.name
)));
};
if sequence == 0 || sequence >= GeneratedCycleId::SEQUENCE_LIMIT {
return Err(TixError::Config(format!(
"cycle `{}` carries generated sequence `{sequence}`; sequences start at 1 and \
must fit {} digits",
record.name,
GeneratedCycleId::SEQUENCE_WIDTH
)));
}
let (Some(start_date), Some(due_date)) =
(record.start_date.clone(), record.due_date.clone())
else {
return Err(TixError::Config(format!(
"generated cycle `{}` needs both `start_date` and `due_date`",
record.name
)));
};
let schedule_id = CycleScheduleId::new(schedule_raw)?;
let state = GeneratedCycleState::parse(state_raw)?;
let id = GeneratedCycleId::new(&schedule_id, sequence);
Ok(Some(Self {
id,
schedule_id,
sequence,
name: record.name.clone(),
start_date,
due_date,
state,
}))
}
#[must_use]
pub fn to_record(&self) -> CycleRecord {
CycleRecord {
name: self.name.clone(),
description: None,
start_date: Some(self.start_date.clone()),
due_date: Some(self.due_date.clone()),
schedule_id: Some(self.schedule_id.as_str().to_string()),
sequence: Some(self.sequence),
state: Some(self.state.as_str().to_string()),
}
}
#[must_use]
pub fn is_active(&self) -> bool {
self.state == GeneratedCycleState::Active
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NextCycleKey {
pub schedule_id: CycleScheduleId,
pub sequence: u32,
pub id: GeneratedCycleId,
pub start_date: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct GenerationCursor {
#[serde(skip_serializing_if = "Option::is_none")]
pub next_sequence: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_start: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_generated_key: Option<GeneratedCycleId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub look_ahead_applied_through: Option<u32>,
#[serde(flatten)]
pub extensions: CycleScheduleExtensions,
}
impl GenerationCursor {
#[must_use]
pub fn is_empty(&self) -> bool {
self == &Self::default()
}
pub const KNOWN_FIELDS: &'static [&'static str] = &[
"next_sequence",
"next_start",
"last_generated_key",
"look_ahead_applied_through",
];
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CycleReference {
Legacy {
name: String,
},
Generated {
name: String,
id: GeneratedCycleId,
schedule_id: CycleScheduleId,
},
Unknown {
name: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdentityDiagnosticKind {
MissingSchedule,
DuplicateSequence,
IdentityMismatch,
ReferenceCollision,
CursorDrift,
MalformedIdentity,
}
impl IdentityDiagnosticKind {
pub const fn as_str(self) -> &'static str {
match self {
IdentityDiagnosticKind::MissingSchedule => "missing_schedule",
IdentityDiagnosticKind::DuplicateSequence => "duplicate_sequence",
IdentityDiagnosticKind::IdentityMismatch => "identity_mismatch",
IdentityDiagnosticKind::ReferenceCollision => "reference_collision",
IdentityDiagnosticKind::CursorDrift => "cursor_drift",
IdentityDiagnosticKind::MalformedIdentity => "malformed_identity",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdentityDiagnostic {
pub kind: IdentityDiagnosticKind,
pub schedule_id: Option<CycleScheduleId>,
pub cycle_id: Option<GeneratedCycleId>,
pub field: String,
pub message: String,
}
impl IdentityDiagnostic {
fn new(
kind: IdentityDiagnosticKind,
schedule_id: Option<CycleScheduleId>,
field: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
kind,
schedule_id,
cycle_id: None,
field: field.into(),
message: message.into(),
}
}
#[must_use]
fn on_cycle(mut self, id: GeneratedCycleId) -> Self {
self.cycle_id = Some(id);
self
}
#[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)]
struct IndexedCycle {
cycle: GeneratedCycle,
}
#[derive(Debug, Clone, Default)]
pub struct ScheduleIndex {
schedules: IndexMap<CycleScheduleId, CycleScheduleConfiguration>,
by_name: BTreeMap<String, CycleScheduleId>,
cycles: BTreeMap<GeneratedCycleId, IndexedCycle>,
by_schedule: BTreeMap<CycleScheduleId, BTreeMap<u32, GeneratedCycleId>>,
by_reference: BTreeMap<String, GeneratedCycleId>,
legacy_names: BTreeSet<String>,
diagnostics: Vec<IdentityDiagnostic>,
}
impl ScheduleIndex {
#[must_use]
pub fn build(config: &Config, records: &[CycleRecord]) -> Self {
let mut index = Self::default();
for (raw, schedule) in &config.cycle_schedules {
let Ok(id) = CycleScheduleId::new(raw) else {
continue;
};
index
.by_name
.insert(schedule.name.trim().to_lowercase(), id.clone());
index.schedules.insert(id, schedule.clone());
}
for record in records {
index.add_record(record);
}
index.reconcile_cursors(config);
index
}
fn add_record(&mut self, record: &CycleRecord) {
let parsed = match GeneratedCycle::from_record(record) {
Ok(parsed) => parsed,
Err(err) => {
self.diagnostics.push(IdentityDiagnostic::new(
IdentityDiagnosticKind::MalformedIdentity,
None,
format!("cycles.{}", record.name),
err.to_string(),
));
return;
}
};
let Some(cycle) = parsed else {
if let Some(existing) = self.by_reference.get(&record.name) {
self.diagnostics.push(
IdentityDiagnostic::new(
IdentityDiagnosticKind::ReferenceCollision,
None,
format!("cycles.{}", record.name),
format!(
"hand-written cycle `{}` and generated cycle `{existing}` both answer \
to the name `{}`; an issue reference would resolve to whichever is \
read first",
record.name, record.name
),
)
.on_cycle(existing.clone()),
);
}
self.legacy_names.insert(record.name.clone());
return;
};
let schedule = cycle.schedule_id.clone();
let sequence = cycle.sequence;
let id = cycle.id.clone();
if !self.schedules.contains_key(&schedule) {
self.diagnostics.push(
IdentityDiagnostic::new(
IdentityDiagnosticKind::MissingSchedule,
Some(schedule.clone()),
format!("{id}.schedule_id"),
format!(
"generated cycle `{id}` names schedule `{schedule}`, which is not in \
`cycle_schedules`; the cycle is kept and reported rather than rebound"
),
)
.on_cycle(id.clone()),
);
}
if id.sequence() != sequence {
self.diagnostics.push(
IdentityDiagnostic::new(
IdentityDiagnosticKind::IdentityMismatch,
Some(schedule.clone()),
format!("{id}.sequence"),
format!("generated cycle `{id}` disagrees with its own sequence `{sequence}`"),
)
.on_cycle(id.clone()),
);
}
if self
.by_schedule
.get(&schedule)
.is_some_and(|sequences| sequences.contains_key(&sequence))
{
self.diagnostics.push(
IdentityDiagnostic::new(
IdentityDiagnosticKind::DuplicateSequence,
Some(schedule.clone()),
format!("cycle_schedules.{schedule}.sequences.{sequence}"),
format!("two cycles claim sequence {sequence} of schedule `{schedule}`"),
)
.on_cycle(id.clone()),
);
}
let claimed_by_generated = self
.by_reference
.get(&cycle.name)
.filter(|other| *other != &id);
let claimed_by_legacy = self.legacy_names.contains(&cycle.name);
if claimed_by_generated.is_some() || claimed_by_legacy {
let other = match claimed_by_generated {
Some(existing) => format!("generated cycle `{existing}`"),
None => "a hand-written cycle".to_string(),
};
self.diagnostics.push(
IdentityDiagnostic::new(
IdentityDiagnosticKind::ReferenceCollision,
Some(schedule.clone()),
format!("cycles.{}", cycle.name),
format!(
"generated cycle `{id}` and {other} both answer to the name `{}`; an \
issue reference would resolve to whichever is read first",
cycle.name
),
)
.on_cycle(id.clone()),
);
}
self.by_reference.insert(cycle.name.clone(), id.clone());
self.by_schedule
.entry(schedule)
.or_default()
.insert(sequence, id.clone());
self.cycles.insert(id, IndexedCycle { cycle });
}
fn reconcile_cursors(&mut self, config: &Config) {
for (id, schedule) in &config.cycle_schedules {
let Ok(schedule_id) = CycleScheduleId::new(id) else {
continue;
};
let Some(stored) = schedule.cursor.as_ref() else {
continue;
};
if stored.is_empty() {
continue;
}
let derived = self.derive_cursor(&schedule_id);
for (field, stored_value, derived_value) in [
(
"next_sequence",
stored.next_sequence.map(|v| v.to_string()),
derived.next_sequence.map(|v| v.to_string()),
),
(
"next_start",
stored.next_start.clone(),
derived.next_start.clone(),
),
(
"last_generated_key",
stored
.last_generated_key
.as_ref()
.map(|k| k.as_str().to_string()),
derived
.last_generated_key
.as_ref()
.map(|k| k.as_str().to_string()),
),
(
"look_ahead_applied_through",
stored.look_ahead_applied_through.map(|v| v.to_string()),
derived.look_ahead_applied_through.map(|v| v.to_string()),
),
] {
if stored_value != derived_value {
self.diagnostics.push(IdentityDiagnostic::new(
IdentityDiagnosticKind::CursorDrift,
Some(schedule_id.clone()),
format!("cycle_schedules.{schedule_id}.cursor.{field}"),
format!(
"the stored cursor field `{field}` says {} but the generated cycles \
say {}; the instances are authoritative and the stored value is \
stale",
stored_value.as_deref().unwrap_or("nothing"),
derived_value.as_deref().unwrap_or("nothing")
),
));
}
}
}
}
pub fn schedules(
&self,
) -> impl Iterator<Item = (&CycleScheduleId, &CycleScheduleConfiguration)> {
self.schedules.iter()
}
#[must_use]
pub fn schedule(&self, id: &CycleScheduleId) -> Option<&CycleScheduleConfiguration> {
self.schedules.get(id)
}
#[must_use]
pub fn schedule_by_name(&self, name: &str) -> Option<&CycleScheduleConfiguration> {
self.by_name
.get(&name.trim().to_lowercase())
.and_then(|id| self.schedules.get(id))
}
#[must_use]
pub fn schedule_count(&self) -> usize {
self.schedules.len()
}
#[must_use]
pub fn cycle(&self, id: &GeneratedCycleId) -> Option<&GeneratedCycle> {
self.cycles.get(id).map(|indexed| &indexed.cycle)
}
pub fn cycles_of(
&self,
schedule: &CycleScheduleId,
) -> impl Iterator<Item = &GeneratedCycle> + '_ {
self.by_schedule
.get(schedule)
.into_iter()
.flat_map(|sequences| sequences.values())
.filter_map(|id| self.cycle(id))
}
#[must_use]
pub fn generated_count(&self) -> usize {
self.cycles.len()
}
#[must_use]
pub fn is_consistent(&self) -> bool {
self.diagnostics.is_empty()
}
#[must_use]
pub fn diagnostics(&self) -> &[IdentityDiagnostic] {
&self.diagnostics
}
#[must_use]
pub fn resolve(&self, name: &str) -> CycleReference {
if let Some(id) = self.by_reference.get(name)
&& let Some(cycle) = self.cycle(id)
{
return CycleReference::Generated {
name: name.to_string(),
id: id.clone(),
schedule_id: cycle.schedule_id.clone(),
};
}
if self.legacy_names.contains(name) {
return CycleReference::Legacy {
name: name.to_string(),
};
}
CycleReference::Unknown {
name: name.to_string(),
}
}
#[must_use]
pub fn cursor(&self, schedule: &CycleScheduleId) -> GenerationCursor {
self.derive_cursor(schedule)
}
fn derive_cursor(&self, schedule: &CycleScheduleId) -> GenerationCursor {
let last = self
.by_schedule
.get(schedule)
.and_then(|sequences| sequences.iter().next_back());
let Some((sequence, id)) = last else {
return GenerationCursor {
next_sequence: Some(1),
next_start: self
.schedules
.get(schedule)
.map(|record| record.start_date.clone()),
last_generated_key: None,
look_ahead_applied_through: None,
extensions: CycleScheduleExtensions::default(),
};
};
let Some(cycle) = self.cycle(id) else {
return GenerationCursor::default();
};
GenerationCursor {
next_sequence: Some(sequence.saturating_add(1)),
next_start: self.next_start_after(schedule, &cycle.start_date),
last_generated_key: Some(id.clone()),
look_ahead_applied_through: Some(*sequence),
extensions: CycleScheduleExtensions::default(),
}
}
fn next_start_after(&self, schedule: &CycleScheduleId, after: &str) -> Option<String> {
let record = self.schedules.get(schedule)?;
let rule = record.cadence.to_rule(record)?;
let date = date_value(after)?;
rule.next_occurrence(&RuleContext::on(date))
.ok()
.map(|key| key.as_str().to_string())
}
#[must_use]
pub fn next_key(&self, schedule: &CycleScheduleId) -> Option<NextCycleKey> {
let record = self.schedules.get(schedule)?;
if record.state.is_terminal() {
return None;
}
let cursor = self.cursor(schedule);
let sequence = cursor.next_sequence?;
if sequence >= GeneratedCycleId::SEQUENCE_LIMIT {
return None;
}
Some(NextCycleKey {
id: GeneratedCycleId::new(schedule, sequence),
sequence,
start_date: cursor.next_start.clone(),
schedule_id: schedule.clone(),
})
}
}
fn date_value(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, Month::try_from(month).ok()?, day).ok()
}
#[must_use]
pub fn cadence_rule(record: &CycleScheduleConfiguration) -> Option<RecurrenceRule> {
record.cadence.to_rule(record)
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
fn schedule_id() -> CycleScheduleId {
CycleScheduleId::new("cs-test").unwrap()
}
#[test]
fn a_generated_id_round_trips_and_sorts_by_sequence() {
let schedule = schedule_id();
let first = GeneratedCycleId::new(&schedule, 1);
let tenth = GeneratedCycleId::new(&schedule, 10);
assert_eq!(first.as_str(), "cs-test:000001");
assert_eq!(first.sequence(), 1);
assert_eq!(first.schedule_id(), "cs-test");
assert!(first.belongs_to(&schedule));
assert!(
first < tenth,
"a fixed width is what keeps plain-string order"
);
let parsed = GeneratedCycleId::parse(first.as_str()).unwrap();
assert_eq!(parsed, first);
}
#[test]
fn a_schedule_id_may_itself_contain_a_colon() {
let schedule = CycleScheduleId::new("cs-1:2").unwrap();
let id = GeneratedCycleId::new(&schedule, 7);
assert_eq!(id.as_str(), "cs-1:2:000007");
assert_eq!(id.schedule_id(), "cs-1:2");
assert_eq!(id.sequence(), 7);
assert_eq!(GeneratedCycleId::parse(id.as_str()).unwrap(), id);
}
#[test]
fn the_sequence_limit_is_exactly_the_field_width() {
assert_eq!(
GeneratedCycleId::SEQUENCE_LIMIT,
10u32.pow(GeneratedCycleId::SEQUENCE_WIDTH as u32),
"the limit must be the first sequence the field cannot hold"
);
let schedule = schedule_id();
let last = GeneratedCycleId::new(&schedule, GeneratedCycleId::SEQUENCE_LIMIT - 1);
assert_eq!(
GeneratedCycleId::parse(last.as_str()).unwrap(),
last,
"the highest nameable sequence must round-trip"
);
assert_eq!(last.as_str(), "cs-test:999999");
}
#[test]
fn a_malformed_id_is_refused_with_the_shape_it_wanted() {
for raw in [
"cs-test",
"cs-test:1",
"cs-test:000000",
"cs-test:00000a",
"cs-test:1000000",
] {
let error = GeneratedCycleId::parse(raw).err();
assert!(error.is_some(), "`{raw}` must be refused");
}
}
#[test]
fn a_partial_identity_on_a_record_is_refused_rather_than_half_read() {
let record = CycleRecord {
name: "Sprint 1".into(),
sequence: Some(1),
..CycleRecord::default()
};
let error =
GeneratedCycle::from_record(&record).expect_err("a partial identity is refused");
assert!(error.to_string().contains("written together"), "{error}");
let legacy = CycleRecord {
name: "Cycle 1".into(),
..CycleRecord::default()
};
assert!(GeneratedCycle::from_record(&legacy).unwrap().is_none());
}
}