Skip to main content

tix/cycle_schedule/
identity.rs

1//! Stable cycle identity, sequence keys, the generation cursor, and the legacy
2//! adapter (WP02, T007–T012).
3//!
4//! # What is identity, and what is only a label
5//!
6//! A generated cycle's identity is **the schedule it came from plus its sequence**
7//! within that schedule — `[{schedule-id}:{sequence}]`, held by [`GeneratedCycleId`].
8//! Nothing else is identity:
9//!
10//! * the cycle's **name** is a mutable reference label. It is the value an issue's
11//!   `cycles` frontmatter holds, so it must stay resolvable by the normal cycle
12//!   machinery (readers, the picker, `tix cycle show`) — which is why a generated
13//!   cycle *is* a [`CycleRecord`](crate::cycle_store::CycleRecord) with its identity
14//!   in three optional fields, and not a second file format;
15//! * the **issue number** is a label;
16//! * the **row position** in a listing is a label.
17//!
18//! Renaming a cycle therefore changes what a human reads and nothing else: the id,
19//! the sequence, and every closed snapshot hanging off them are untouched (C-003).
20//! A schedule's own identity is the same story one level up — [`CycleScheduleId`] is
21//! the registry key, and `name` is a label.
22//!
23//! # The cursor is derived, and that is deliberate
24//!
25//! [`GenerationCursor`] is the data model's persisted position of a schedule, and it
26//! **is** persisted (in `[cycle_schedules.<id>.cursor]`, beside the definition it
27//! belongs to — the same shape the recurring-issues mission uses, where the cursor
28//! lives in the series definition). But it is also *recomputable* from the instances
29//! themselves, and this module always computes that too and reports a disagreement as
30//! a [`IdentityDiagnostic`] rather than trusting the stored copy.
31//!
32//! The reason is the failure the mission names: a cursor that can advance without an
33//! instance — or an instance that can exist without the cursor — is exactly "cursor
34//! drift", and a cursor is only ever an *optimisation* of `max(sequence)`. Recomputing
35//! it costs one pass over a map that is already built, and it turns a silent
36//! inconsistency into a reportable one.
37//!
38//! # Read-only, and indexed once
39//!
40//! [`ScheduleIndex::build`] is the one pass: schedules by id, schedules by display
41//! name, instances by id, instances by schedule and sequence, and instances by the
42//! **name** an issue reference holds. Every accessor after it is a lookup, so an
43//! evaluation over thousands of cycles does not re-read configuration or re-scan
44//! issues (T011, NFR-001). Nothing here writes: the cursor is returned as a value, and
45//! advancing it is a confirmed operation belonging to generation (WP04) and lifecycle
46//! (WP05).
47
48use std::collections::{BTreeMap, BTreeSet};
49use std::fmt;
50
51use indexmap::IndexMap;
52use serde::{Deserialize, Serialize};
53use time::{Date, Month};
54
55use crate::config::Config;
56use crate::cycle_schedule::config::{
57    CycleScheduleConfiguration, CycleScheduleExtensions, CycleScheduleId,
58};
59use crate::cycle_store::CycleRecord;
60use crate::error::{Result, TixError, render_diagnostics};
61use crate::recurrence::rule::{RecurrenceRule, RuleContext};
62
63/// The stable identity of a generated cycle: `{schedule-id}:{sequence}`.
64///
65/// Derived, never allocated: two builds given the same schedule and sequence produce
66/// the same id, which is what makes generation idempotent for a stable key (C-006)
67/// and what lets a snapshot's identity outlive any rename (C-003). The sequence is
68/// zero-padded to a fixed width so ids sort chronologically as plain strings — the
69/// same trick `DateKey` uses, and for the same reason.
70///
71/// The split is on the **last** `:`, because `CycleScheduleId` accepts `:` inside an
72/// id (`cs-1:2` is a legal schedule id) while the tail is always the sequence.
73#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
74#[serde(try_from = "String", into = "String")]
75pub struct GeneratedCycleId(String);
76
77impl GeneratedCycleId {
78    /// How many digits the sequence carries. Six is `999_999` cycles in one schedule
79    /// before the width stops sorting — far past the mission's 10,000-cycle target, and
80    /// a wider field would only make every id longer.
81    pub const SEQUENCE_WIDTH: usize = 6;
82
83    /// The first sequence that no longer fits [`Self::SEQUENCE_WIDTH`], i.e. `10^6`.
84    ///
85    /// Literal because `10u32.pow` is not `const`; the unit suite asserts the two stay
86    /// in step. A sequence at or above this can never be named by a
87    /// [`GeneratedCycleId`], so it is refused where it enters ([`GeneratedCycle::from_record`])
88    /// and never handed out ([`ScheduleIndex::next_key`]).
89    pub const SEQUENCE_LIMIT: u32 = 1_000_000;
90
91    /// The id for `sequence` within `schedule`.
92    ///
93    /// A sequence of zero is refused: sequences start at 1, so `0` is a bug in the
94    /// caller rather than a cycle to name.
95    #[must_use]
96    pub fn new(schedule: &CycleScheduleId, sequence: u32) -> Self {
97        debug_assert!(sequence > 0, "sequences start at 1");
98        Self(format!(
99            "{}:{:0width$}",
100            schedule.as_str(),
101            sequence,
102            width = Self::SEQUENCE_WIDTH
103        ))
104    }
105
106    /// Parse a stored id, validating the shape the other registries' ids follow.
107    pub fn parse(raw: &str) -> Result<Self> {
108        let (schedule, sequence) = raw.rsplit_once(':').ok_or_else(|| {
109            TixError::Config(format!(
110                "generated cycle id `{raw}` must be `<schedule-id>:<sequence>`"
111            ))
112        })?;
113        CycleScheduleId::new(schedule)
114            .map_err(|err| TixError::Config(format!("generated cycle id `{raw}`: {err}")))?;
115        if sequence.len() != Self::SEQUENCE_WIDTH || !sequence.bytes().all(|b| b.is_ascii_digit()) {
116            return Err(TixError::Config(format!(
117                "generated cycle id `{raw}` must carry a {}-digit sequence",
118                Self::SEQUENCE_WIDTH
119            )));
120        }
121        let parsed: u32 = sequence.parse().map_err(|_| {
122            TixError::Config(format!(
123                "generated cycle id `{raw}` has an unreadable sequence"
124            ))
125        })?;
126        if parsed == 0 {
127            return Err(TixError::Config(format!(
128                "generated cycle id `{raw}` has sequence 0; sequences start at 1"
129            )));
130        }
131        Ok(Self(raw.to_string()))
132    }
133
134    /// The id as written.
135    #[must_use]
136    pub fn as_str(&self) -> &str {
137        &self.0
138    }
139
140    /// The owning schedule's id, as written.
141    #[must_use]
142    pub fn schedule_id(&self) -> &str {
143        self.0
144            .rsplit_once(':')
145            .map_or(self.0.as_str(), |(schedule, _)| schedule)
146    }
147
148    /// The sequence within the schedule.
149    #[must_use]
150    pub fn sequence(&self) -> u32 {
151        self.0
152            .rsplit_once(':')
153            .and_then(|(_, sequence)| sequence.parse().ok())
154            .unwrap_or(0)
155    }
156
157    /// Whether `schedule` owns this id.
158    #[must_use]
159    pub fn belongs_to(&self, schedule: &CycleScheduleId) -> bool {
160        self.schedule_id() == schedule.as_str()
161    }
162}
163
164impl fmt::Display for GeneratedCycleId {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        f.write_str(&self.0)
167    }
168}
169
170impl TryFrom<String> for GeneratedCycleId {
171    type Error = TixError;
172
173    fn try_from(raw: String) -> Result<Self> {
174        Self::parse(&raw)
175    }
176}
177
178impl From<GeneratedCycleId> for String {
179    fn from(id: GeneratedCycleId) -> Self {
180        id.0
181    }
182}
183
184/// A generated cycle's lifecycle state (data-model §2).
185///
186/// `closed` and `skipped` are terminal: a closed cycle has a snapshot, and a skipped
187/// sequence stays recorded so it is never silently regenerated (C-006). `paused` is the
188/// schedule's own state applied to one instance.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
190#[serde(rename_all = "kebab-case")]
191pub enum GeneratedCycleState {
192    /// Generated, running, and the current candidate.
193    #[default]
194    Active,
195    /// Explicitly closed; its snapshot is the historical record.
196    Closed,
197    /// Explicitly skipped; the sequence is consumed and stays recorded.
198    Skipped,
199    /// Explicitly paused with the schedule.
200    Paused,
201}
202
203impl GeneratedCycleState {
204    /// The contract's spelling, used verbatim in payloads and diagnostics.
205    pub const fn as_str(self) -> &'static str {
206        match self {
207            GeneratedCycleState::Active => "active",
208            GeneratedCycleState::Closed => "closed",
209            GeneratedCycleState::Skipped => "skipped",
210            GeneratedCycleState::Paused => "paused",
211        }
212    }
213
214    /// Parse a stored spelling.
215    pub fn parse(raw: &str) -> Result<Self> {
216        match raw {
217            "active" => Ok(Self::Active),
218            "closed" => Ok(Self::Closed),
219            "skipped" => Ok(Self::Skipped),
220            "paused" => Ok(Self::Paused),
221            other => Err(TixError::Config(format!(
222                "generated cycle state `{other}` is not one of `active`, `closed`, `skipped`, `paused`"
223            ))),
224        }
225    }
226
227    /// Whether no further transition leaves this state.
228    pub const fn is_terminal(self) -> bool {
229        matches!(self, Self::Closed | Self::Skipped)
230    }
231}
232
233/// One generated cycle: its stable identity plus the fields its cycle record carries.
234///
235/// Built from a [`CycleRecord`] and written back to one, so the file a user can read
236/// and the identity the machinery keys on are the same artifact.
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub struct GeneratedCycle {
239    /// The stable identity.
240    pub id: GeneratedCycleId,
241    /// The schedule that generated it.
242    pub schedule_id: CycleScheduleId,
243    /// The sequence within the schedule.
244    pub sequence: u32,
245    /// The reference label an issue's `cycles` value holds.
246    pub name: String,
247    /// The effective start date.
248    pub start_date: String,
249    /// The effective due date.
250    pub due_date: String,
251    /// The instance's lifecycle state.
252    pub state: GeneratedCycleState,
253}
254
255impl GeneratedCycle {
256    /// The instance a [`CycleRecord`] carries, or `None` when the record is a
257    /// hand-written cycle.
258    ///
259    /// A record with *some* identity fields but not all of them is refused rather than
260    /// half-read: a sequence without a schedule (or the reverse) cannot resolve to an
261    /// id, and inventing the missing half is exactly the guess this mission forbids.
262    pub fn from_record(record: &CycleRecord) -> Result<Option<Self>> {
263        let (Some(schedule_raw), Some(sequence), Some(state_raw)) = (
264            record.schedule_id.as_deref(),
265            record.sequence,
266            record.state.as_deref(),
267        ) else {
268            if record.schedule_id.is_none() && record.sequence.is_none() && record.state.is_none() {
269                return Ok(None);
270            }
271            return Err(TixError::Config(format!(
272                "cycle `{}` carries a partial generated identity: `schedule_id`, `sequence` \
273                 and `state` are written together or not at all",
274                record.name
275            )));
276        };
277        // The sequence is the untrusted side: it comes from a file a human can edit, and
278        // an id built from one that does not fit the field would be refused by
279        // `GeneratedCycleId::parse` — i.e. this module would index, and the cursor would
280        // later store, an identity nothing can read back. Refuse it as a finding instead.
281        if sequence == 0 || sequence >= GeneratedCycleId::SEQUENCE_LIMIT {
282            return Err(TixError::Config(format!(
283                "cycle `{}` carries generated sequence `{sequence}`; sequences start at 1 and \
284                 must fit {} digits",
285                record.name,
286                GeneratedCycleId::SEQUENCE_WIDTH
287            )));
288        }
289        // A generated cycle is a *box with dates*: evaluation and generation both need
290        // the interval. Absence is refused rather than carried as an empty string, which
291        // would re-serialize as a blank date the loader itself rejects.
292        let (Some(start_date), Some(due_date)) =
293            (record.start_date.clone(), record.due_date.clone())
294        else {
295            return Err(TixError::Config(format!(
296                "generated cycle `{}` needs both `start_date` and `due_date`",
297                record.name
298            )));
299        };
300        let schedule_id = CycleScheduleId::new(schedule_raw)?;
301        let state = GeneratedCycleState::parse(state_raw)?;
302        let id = GeneratedCycleId::new(&schedule_id, sequence);
303        Ok(Some(Self {
304            id,
305            schedule_id,
306            sequence,
307            name: record.name.clone(),
308            start_date,
309            due_date,
310            state,
311        }))
312    }
313
314    /// This instance as the cycle record to store.
315    #[must_use]
316    pub fn to_record(&self) -> CycleRecord {
317        CycleRecord {
318            name: self.name.clone(),
319            description: None,
320            start_date: Some(self.start_date.clone()),
321            due_date: Some(self.due_date.clone()),
322            schedule_id: Some(self.schedule_id.as_str().to_string()),
323            sequence: Some(self.sequence),
324            state: Some(self.state.as_str().to_string()),
325        }
326    }
327
328    /// Whether this instance still counts as an active candidate.
329    #[must_use]
330    pub fn is_active(&self) -> bool {
331        self.state == GeneratedCycleState::Active
332    }
333}
334
335/// The next candidate a confirmed generation would create for a schedule (T009).
336///
337/// A **value**, computed without touching anything: asking twice returns the same
338/// answer, and asking never advances a cursor or creates a cycle (C-004).
339#[derive(Debug, Clone, PartialEq, Eq)]
340pub struct NextCycleKey {
341    /// The schedule the candidate belongs to.
342    pub schedule_id: CycleScheduleId,
343    /// The sequence it would take.
344    pub sequence: u32,
345    /// Its stable identity.
346    pub id: GeneratedCycleId,
347    /// Its start date, or `None` when the cadence cannot be resolved to a date (an
348    /// unreachable state for a loaded schedule, since the configuration is validated
349    /// first — reported rather than guessed if it ever happens).
350    pub start_date: Option<String>,
351}
352
353/// The persisted generation position of a schedule (data-model §3).
354///
355/// Stored in `[cycle_schedules.<id>.cursor]`. Every field is optional and omitted when
356/// absent, so a schedule that has never generated carries no cursor at all and a
357/// legacy `tix.toml` gains no key.
358///
359/// See the module note: this value is **reconciled** against the instances rather than
360/// trusted, so `next_sequence` here is a fast path and `max(sequence) + 1` is the
361/// truth.
362#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
363#[serde(default)]
364pub struct GenerationCursor {
365    /// The next candidate sequence.
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub next_sequence: Option<u32>,
368    /// The next candidate start date.
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub next_start: Option<String>,
371    /// The id most recently generated, as progress evidence.
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub last_generated_key: Option<GeneratedCycleId>,
374    /// The last sequence the look-ahead covered.
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub look_ahead_applied_through: Option<u32>,
377    /// Unknown metadata, preserved verbatim.
378    #[serde(flatten)]
379    pub extensions: CycleScheduleExtensions,
380}
381
382impl GenerationCursor {
383    /// Whether nothing is recorded yet.
384    #[must_use]
385    pub fn is_empty(&self) -> bool {
386        self == &Self::default()
387    }
388
389    /// The recognized cursor fields, for the extension-shadow check.
390    pub const KNOWN_FIELDS: &'static [&'static str] = &[
391        "next_sequence",
392        "next_start",
393        "last_generated_key",
394        "look_ahead_applied_through",
395    ];
396}
397
398/// What a cycle reference resolves to (T010).
399///
400/// The value an issue's `cycles` frontmatter holds is a **name**, and a name may be a
401/// hand-written cycle or a generated one. Resolution is therefore a question with three
402/// answers, and the third one matters: an unknown name is reported, never rebound to
403/// whatever record happens to be nearby.
404#[derive(Debug, Clone, PartialEq, Eq)]
405pub enum CycleReference {
406    /// A hand-written cycle in the registry.
407    Legacy {
408        /// The name that was resolved.
409        name: String,
410    },
411    /// A cycle a schedule generated.
412    Generated {
413        /// The name that was resolved.
414        name: String,
415        /// The stable identity behind the name.
416        id: GeneratedCycleId,
417        /// The generating schedule.
418        schedule_id: CycleScheduleId,
419    },
420    /// Nothing by that name.
421    Unknown {
422        /// The name that did not resolve.
423        name: String,
424    },
425}
426
427/// What an identity problem is, spelled in `snake_case` for payloads and tests.
428#[derive(Debug, Clone, Copy, PartialEq, Eq)]
429pub enum IdentityDiagnosticKind {
430    /// A generated cycle names a schedule that is not in the registry.
431    MissingSchedule,
432    /// Two records claim one `(schedule, sequence)`.
433    DuplicateSequence,
434    /// A record's id disagrees with its own schedule/sequence pair.
435    IdentityMismatch,
436    /// Two cycles claim one reference name.
437    ReferenceCollision,
438    /// The stored cursor disagrees with the instances.
439    CursorDrift,
440    /// A record carries a partial identity or an unreadable state.
441    MalformedIdentity,
442}
443
444impl IdentityDiagnosticKind {
445    /// The contract's spelling.
446    pub const fn as_str(self) -> &'static str {
447        match self {
448            IdentityDiagnosticKind::MissingSchedule => "missing_schedule",
449            IdentityDiagnosticKind::DuplicateSequence => "duplicate_sequence",
450            IdentityDiagnosticKind::IdentityMismatch => "identity_mismatch",
451            IdentityDiagnosticKind::ReferenceCollision => "reference_collision",
452            IdentityDiagnosticKind::CursorDrift => "cursor_drift",
453            IdentityDiagnosticKind::MalformedIdentity => "malformed_identity",
454        }
455    }
456}
457
458/// One identity finding, produced by [`ScheduleIndex::build`].
459///
460/// Rendered through the crate's one diagnostic renderer, so `tix lint`, the CLI and
461/// MCP spell the same refusal the same way.
462#[derive(Debug, Clone, PartialEq, Eq)]
463pub struct IdentityDiagnostic {
464    /// What is wrong.
465    pub kind: IdentityDiagnosticKind,
466    /// The schedule it concerns, when the finding has one.
467    pub schedule_id: Option<CycleScheduleId>,
468    /// The cycle it concerns, when the finding has one.
469    pub cycle_id: Option<GeneratedCycleId>,
470    /// The field path, e.g. `cycle_schedules.cs-1.cursor.next_sequence`.
471    pub field: String,
472    /// One sentence saying what to do about it.
473    pub message: String,
474}
475
476impl IdentityDiagnostic {
477    /// A finding about `field`.
478    fn new(
479        kind: IdentityDiagnosticKind,
480        schedule_id: Option<CycleScheduleId>,
481        field: impl Into<String>,
482        message: impl Into<String>,
483    ) -> Self {
484        Self {
485            kind,
486            schedule_id,
487            cycle_id: None,
488            field: field.into(),
489            message: message.into(),
490        }
491    }
492
493    /// Attach the cycle the finding came from.
494    #[must_use]
495    fn on_cycle(mut self, id: GeneratedCycleId) -> Self {
496        self.cycle_id = Some(id);
497        self
498    }
499
500    /// Render through the crate's one diagnostic renderer.
501    #[must_use]
502    pub fn render(&self) -> String {
503        render_diagnostics(
504            "E-tix-cycle-schedule",
505            std::iter::once((self.kind.as_str(), self.message.as_str())),
506        )
507    }
508}
509
510/// One entry of a cycle record as the index sees it: the record and where it came from.
511#[derive(Debug, Clone, PartialEq, Eq)]
512struct IndexedCycle {
513    cycle: GeneratedCycle,
514}
515
516/// A one-pass, read-only index over schedules and the cycles they generated (T011).
517///
518/// Built from the configuration and the cycle records that already exist; every
519/// question after that is a map lookup. Nothing here reads a clock, writes a file,
520/// advances a cursor or appends History — the whole type is a value.
521#[derive(Debug, Clone, Default)]
522pub struct ScheduleIndex {
523    schedules: IndexMap<CycleScheduleId, CycleScheduleConfiguration>,
524    by_name: BTreeMap<String, CycleScheduleId>,
525    cycles: BTreeMap<GeneratedCycleId, IndexedCycle>,
526    by_schedule: BTreeMap<CycleScheduleId, BTreeMap<u32, GeneratedCycleId>>,
527    by_reference: BTreeMap<String, GeneratedCycleId>,
528    legacy_names: BTreeSet<String>,
529    diagnostics: Vec<IdentityDiagnostic>,
530}
531
532impl ScheduleIndex {
533    /// Build the index from the configuration and every stored cycle record.
534    ///
535    /// The records are what [`crate::cycle_store::CycleStore::list`] returns: hand-written
536    /// cycles and generated ones together, because a generated cycle *is* a cycle. A
537    /// record with no identity fields is indexed as a legacy name and nothing else.
538    ///
539    /// Findings are collected rather than refused: an index that cannot be built cannot
540    /// be diagnosed, and a hand-edited file must stay loadable (C-005).
541    #[must_use]
542    pub fn build(config: &Config, records: &[CycleRecord]) -> Self {
543        let mut index = Self::default();
544        for (raw, schedule) in &config.cycle_schedules {
545            let Ok(id) = CycleScheduleId::new(raw) else {
546                continue;
547            };
548            index
549                .by_name
550                .insert(schedule.name.trim().to_lowercase(), id.clone());
551            index.schedules.insert(id, schedule.clone());
552        }
553        for record in records {
554            index.add_record(record);
555        }
556        index.reconcile_cursors(config);
557        index
558    }
559
560    /// Index one cycle record.
561    fn add_record(&mut self, record: &CycleRecord) {
562        let parsed = match GeneratedCycle::from_record(record) {
563            Ok(parsed) => parsed,
564            Err(err) => {
565                self.diagnostics.push(IdentityDiagnostic::new(
566                    IdentityDiagnosticKind::MalformedIdentity,
567                    None,
568                    format!("cycles.{}", record.name),
569                    err.to_string(),
570                ));
571                return;
572            }
573        };
574        let Some(cycle) = parsed else {
575            // The reverse direction of the same collision: a hand-written cycle claiming a
576            // name a generated cycle already took (the records arrive in file order, so
577            // either kind can be the second one to arrive).
578            if let Some(existing) = self.by_reference.get(&record.name) {
579                self.diagnostics.push(
580                    IdentityDiagnostic::new(
581                        IdentityDiagnosticKind::ReferenceCollision,
582                        None,
583                        format!("cycles.{}", record.name),
584                        format!(
585                            "hand-written cycle `{}` and generated cycle `{existing}` both answer \
586                             to the name `{}`; an issue reference would resolve to whichever is \
587                             read first",
588                            record.name, record.name
589                        ),
590                    )
591                    .on_cycle(existing.clone()),
592                );
593            }
594            self.legacy_names.insert(record.name.clone());
595            return;
596        };
597        let schedule = cycle.schedule_id.clone();
598        let sequence = cycle.sequence;
599        let id = cycle.id.clone();
600
601        if !self.schedules.contains_key(&schedule) {
602            self.diagnostics.push(
603                IdentityDiagnostic::new(
604                    IdentityDiagnosticKind::MissingSchedule,
605                    Some(schedule.clone()),
606                    format!("{id}.schedule_id"),
607                    format!(
608                        "generated cycle `{id}` names schedule `{schedule}`, which is not in \
609                         `cycle_schedules`; the cycle is kept and reported rather than rebound"
610                    ),
611                )
612                .on_cycle(id.clone()),
613            );
614        }
615        if id.sequence() != sequence {
616            self.diagnostics.push(
617                IdentityDiagnostic::new(
618                    IdentityDiagnosticKind::IdentityMismatch,
619                    Some(schedule.clone()),
620                    format!("{id}.sequence"),
621                    format!("generated cycle `{id}` disagrees with its own sequence `{sequence}`"),
622                )
623                .on_cycle(id.clone()),
624            );
625        }
626        if self
627            .by_schedule
628            .get(&schedule)
629            .is_some_and(|sequences| sequences.contains_key(&sequence))
630        {
631            self.diagnostics.push(
632                IdentityDiagnostic::new(
633                    IdentityDiagnosticKind::DuplicateSequence,
634                    Some(schedule.clone()),
635                    format!("cycle_schedules.{schedule}.sequences.{sequence}"),
636                    format!("two cycles claim sequence {sequence} of schedule `{schedule}`"),
637                )
638                .on_cycle(id.clone()),
639            );
640        }
641        // A name must identify exactly one cycle, and that has to hold across the two
642        // kinds: a generated cycle whose name a hand-written cycle already answers to is
643        // the same collision as two generated cycles sharing one, and reporting only the
644        // latter is what let `resolve` silently shadow the hand-written cycle.
645        let claimed_by_generated = self
646            .by_reference
647            .get(&cycle.name)
648            .filter(|other| *other != &id);
649        let claimed_by_legacy = self.legacy_names.contains(&cycle.name);
650        if claimed_by_generated.is_some() || claimed_by_legacy {
651            let other = match claimed_by_generated {
652                Some(existing) => format!("generated cycle `{existing}`"),
653                None => "a hand-written cycle".to_string(),
654            };
655            self.diagnostics.push(
656                IdentityDiagnostic::new(
657                    IdentityDiagnosticKind::ReferenceCollision,
658                    Some(schedule.clone()),
659                    format!("cycles.{}", cycle.name),
660                    format!(
661                        "generated cycle `{id}` and {other} both answer to the name `{}`; an \
662                         issue reference would resolve to whichever is read first",
663                        cycle.name
664                    ),
665                )
666                .on_cycle(id.clone()),
667            );
668        }
669
670        self.by_reference.insert(cycle.name.clone(), id.clone());
671        self.by_schedule
672            .entry(schedule)
673            .or_default()
674            .insert(sequence, id.clone());
675        self.cycles.insert(id, IndexedCycle { cycle });
676    }
677
678    /// Compare every stored cursor against what the instances say.
679    fn reconcile_cursors(&mut self, config: &Config) {
680        for (id, schedule) in &config.cycle_schedules {
681            let Ok(schedule_id) = CycleScheduleId::new(id) else {
682                continue;
683            };
684            let Some(stored) = schedule.cursor.as_ref() else {
685                continue;
686            };
687            // A cursor that records nothing is not a disagreement: `[cycle_schedules.x.cursor]`
688            // written empty (or by a tool that wrote the table before its first generation)
689            // agrees with an empty instance set by definition.
690            if stored.is_empty() {
691                continue;
692            }
693            let derived = self.derive_cursor(&schedule_id);
694            for (field, stored_value, derived_value) in [
695                (
696                    "next_sequence",
697                    stored.next_sequence.map(|v| v.to_string()),
698                    derived.next_sequence.map(|v| v.to_string()),
699                ),
700                (
701                    "next_start",
702                    stored.next_start.clone(),
703                    derived.next_start.clone(),
704                ),
705                (
706                    "last_generated_key",
707                    stored
708                        .last_generated_key
709                        .as_ref()
710                        .map(|k| k.as_str().to_string()),
711                    derived
712                        .last_generated_key
713                        .as_ref()
714                        .map(|k| k.as_str().to_string()),
715                ),
716                (
717                    "look_ahead_applied_through",
718                    stored.look_ahead_applied_through.map(|v| v.to_string()),
719                    derived.look_ahead_applied_through.map(|v| v.to_string()),
720                ),
721            ] {
722                if stored_value != derived_value {
723                    self.diagnostics.push(IdentityDiagnostic::new(
724                        IdentityDiagnosticKind::CursorDrift,
725                        Some(schedule_id.clone()),
726                        format!("cycle_schedules.{schedule_id}.cursor.{field}"),
727                        format!(
728                            "the stored cursor field `{field}` says {} but the generated cycles \
729                             say {}; the instances are authoritative and the stored value is \
730                             stale",
731                            stored_value.as_deref().unwrap_or("nothing"),
732                            derived_value.as_deref().unwrap_or("nothing")
733                        ),
734                    ));
735                }
736            }
737        }
738    }
739
740    /// Every schedule, in declaration order.
741    pub fn schedules(
742        &self,
743    ) -> impl Iterator<Item = (&CycleScheduleId, &CycleScheduleConfiguration)> {
744        self.schedules.iter()
745    }
746
747    /// One schedule by its stable id.
748    #[must_use]
749    pub fn schedule(&self, id: &CycleScheduleId) -> Option<&CycleScheduleConfiguration> {
750        self.schedules.get(id)
751    }
752
753    /// One schedule by its display name (case-folded, as the registry validates it).
754    #[must_use]
755    pub fn schedule_by_name(&self, name: &str) -> Option<&CycleScheduleConfiguration> {
756        self.by_name
757            .get(&name.trim().to_lowercase())
758            .and_then(|id| self.schedules.get(id))
759    }
760
761    /// How many schedules the index holds.
762    #[must_use]
763    pub fn schedule_count(&self) -> usize {
764        self.schedules.len()
765    }
766
767    /// One generated cycle by its stable id.
768    #[must_use]
769    pub fn cycle(&self, id: &GeneratedCycleId) -> Option<&GeneratedCycle> {
770        self.cycles.get(id).map(|indexed| &indexed.cycle)
771    }
772
773    /// The generated cycles of one schedule, in sequence order.
774    pub fn cycles_of(
775        &self,
776        schedule: &CycleScheduleId,
777    ) -> impl Iterator<Item = &GeneratedCycle> + '_ {
778        self.by_schedule
779            .get(schedule)
780            .into_iter()
781            .flat_map(|sequences| sequences.values())
782            .filter_map(|id| self.cycle(id))
783    }
784
785    /// How many generated cycles the index holds.
786    #[must_use]
787    pub fn generated_count(&self) -> usize {
788        self.cycles.len()
789    }
790
791    /// Whether any finding was recorded.
792    #[must_use]
793    pub fn is_consistent(&self) -> bool {
794        self.diagnostics.is_empty()
795    }
796
797    /// Every finding, in a deterministic order.
798    #[must_use]
799    pub fn diagnostics(&self) -> &[IdentityDiagnostic] {
800        &self.diagnostics
801    }
802
803    /// Resolve the name an issue's `cycles` value holds (T010).
804    ///
805    /// A generated cycle, a hand-written cycle, or nothing — and without migrating or
806    /// rewriting either.
807    ///
808    /// A name is meant to identify exactly one cycle, so the two sets are not supposed
809    /// to overlap; if they do (only a hand-edited filename can arrange it, since equal
810    /// names share a file), the collision was already recorded as a
811    /// [`IdentityDiagnosticKind::ReferenceCollision`] finding when the index was built.
812    /// Generated is therefore checked first as a *tie-break that has already been
813    /// reported*, not as a precedence rule the caller should rely on.
814    #[must_use]
815    pub fn resolve(&self, name: &str) -> CycleReference {
816        if let Some(id) = self.by_reference.get(name)
817            && let Some(cycle) = self.cycle(id)
818        {
819            return CycleReference::Generated {
820                name: name.to_string(),
821                id: id.clone(),
822                schedule_id: cycle.schedule_id.clone(),
823            };
824        }
825        if self.legacy_names.contains(name) {
826            return CycleReference::Legacy {
827                name: name.to_string(),
828            };
829        }
830        CycleReference::Unknown {
831            name: name.to_string(),
832        }
833    }
834
835    /// The cursor for `schedule`, computed from the cycles that exist.
836    ///
837    /// Always the **derived** value: `max(sequence) + 1` and the next date are facts
838    /// about the instances, and the stored copy is a cached copy of them. A disagreement
839    /// between the two does not change what this returns — it was recorded as a
840    /// [`IdentityDiagnosticKind::CursorDrift`] finding when the index was built, which is
841    /// the point: the disagreement is reported rather than silently resolved in favour
842    /// of either side.
843    ///
844    /// Reading it never advances anything and never writes.
845    #[must_use]
846    pub fn cursor(&self, schedule: &CycleScheduleId) -> GenerationCursor {
847        self.derive_cursor(schedule)
848    }
849
850    /// What the instances say the position is.
851    fn derive_cursor(&self, schedule: &CycleScheduleId) -> GenerationCursor {
852        let last = self
853            .by_schedule
854            .get(schedule)
855            .and_then(|sequences| sequences.iter().next_back());
856        let Some((sequence, id)) = last else {
857            return GenerationCursor {
858                next_sequence: Some(1),
859                next_start: self
860                    .schedules
861                    .get(schedule)
862                    .map(|record| record.start_date.clone()),
863                last_generated_key: None,
864                look_ahead_applied_through: None,
865                extensions: CycleScheduleExtensions::default(),
866            };
867        };
868        let Some(cycle) = self.cycle(id) else {
869            return GenerationCursor::default();
870        };
871        GenerationCursor {
872            next_sequence: Some(sequence.saturating_add(1)),
873            next_start: self.next_start_after(schedule, &cycle.start_date),
874            last_generated_key: Some(id.clone()),
875            look_ahead_applied_through: Some(*sequence),
876            extensions: CycleScheduleExtensions::default(),
877        }
878    }
879
880    /// One cadence step after `after`, through the shared arithmetic owner.
881    ///
882    /// `None` when the step cannot be resolved — an absent cadence family, or a date the
883    /// rule refuses. It is never a guess: the caller reports "no candidate date" rather
884    /// than inventing one.
885    fn next_start_after(&self, schedule: &CycleScheduleId, after: &str) -> Option<String> {
886        let record = self.schedules.get(schedule)?;
887        let rule = record.cadence.to_rule(record)?;
888        let date = date_value(after)?;
889        rule.next_occurrence(&RuleContext::on(date))
890            .ok()
891            .map(|key| key.as_str().to_string())
892    }
893
894    /// The next candidate key for `schedule`, or `None` when the schedule is unknown or
895    /// has ended (T009/C-004: an ended schedule generates nothing).
896    #[must_use]
897    pub fn next_key(&self, schedule: &CycleScheduleId) -> Option<NextCycleKey> {
898        let record = self.schedules.get(schedule)?;
899        if record.state.is_terminal() {
900            return None;
901        }
902        let cursor = self.cursor(schedule);
903        let sequence = cursor.next_sequence?;
904        // A schedule whose sequences are used up has no next candidate, the same way an
905        // ended one does — and never a *duplicate*, which is what handing back an
906        // already-taken sequence would be (C-006).
907        if sequence >= GeneratedCycleId::SEQUENCE_LIMIT {
908            return None;
909        }
910        Some(NextCycleKey {
911            id: GeneratedCycleId::new(schedule, sequence),
912            sequence,
913            start_date: cursor.next_start.clone(),
914            schedule_id: schedule.clone(),
915        })
916    }
917}
918
919/// A canonical `YYYY-MM-DD` date as the arithmetic owner's value.
920///
921/// The *rule* that a stored date is a real calendar date belongs to
922/// [`crate::model::validate_date`] and has already been applied by the time a schedule
923/// is loaded; this is the string→value conversion the shared [`RuleContext`] needs, so
924/// it lives here once instead of at every call site.
925fn date_value(value: &str) -> Option<Date> {
926    if !crate::model::validate_date(value) {
927        return None;
928    }
929    let year: i32 = value.get(0..4)?.parse().ok()?;
930    let month: u8 = value.get(5..7)?.parse().ok()?;
931    let day: u8 = value.get(8..10)?.parse().ok()?;
932    Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()
933}
934
935/// The rule a schedule's cadence resolves to, for a caller that needs the arithmetic
936/// owner without an index (WP03/WP04).
937#[must_use]
938pub fn cadence_rule(record: &CycleScheduleConfiguration) -> Option<RecurrenceRule> {
939    record.cadence.to_rule(record)
940}
941
942#[cfg(test)]
943mod tests {
944    #[allow(clippy::wildcard_imports)]
945    use super::*;
946
947    fn schedule_id() -> CycleScheduleId {
948        CycleScheduleId::new("cs-test").unwrap()
949    }
950
951    #[test]
952    fn a_generated_id_round_trips_and_sorts_by_sequence() {
953        let schedule = schedule_id();
954        let first = GeneratedCycleId::new(&schedule, 1);
955        let tenth = GeneratedCycleId::new(&schedule, 10);
956        assert_eq!(first.as_str(), "cs-test:000001");
957        assert_eq!(first.sequence(), 1);
958        assert_eq!(first.schedule_id(), "cs-test");
959        assert!(first.belongs_to(&schedule));
960        assert!(
961            first < tenth,
962            "a fixed width is what keeps plain-string order"
963        );
964
965        let parsed = GeneratedCycleId::parse(first.as_str()).unwrap();
966        assert_eq!(parsed, first);
967    }
968
969    #[test]
970    fn a_schedule_id_may_itself_contain_a_colon() {
971        let schedule = CycleScheduleId::new("cs-1:2").unwrap();
972        let id = GeneratedCycleId::new(&schedule, 7);
973        assert_eq!(id.as_str(), "cs-1:2:000007");
974        assert_eq!(id.schedule_id(), "cs-1:2");
975        assert_eq!(id.sequence(), 7);
976        assert_eq!(GeneratedCycleId::parse(id.as_str()).unwrap(), id);
977    }
978
979    #[test]
980    fn the_sequence_limit_is_exactly_the_field_width() {
981        assert_eq!(
982            GeneratedCycleId::SEQUENCE_LIMIT,
983            10u32.pow(GeneratedCycleId::SEQUENCE_WIDTH as u32),
984            "the limit must be the first sequence the field cannot hold"
985        );
986        let schedule = schedule_id();
987        let last = GeneratedCycleId::new(&schedule, GeneratedCycleId::SEQUENCE_LIMIT - 1);
988        assert_eq!(
989            GeneratedCycleId::parse(last.as_str()).unwrap(),
990            last,
991            "the highest nameable sequence must round-trip"
992        );
993        assert_eq!(last.as_str(), "cs-test:999999");
994    }
995
996    #[test]
997    fn a_malformed_id_is_refused_with_the_shape_it_wanted() {
998        for raw in [
999            "cs-test",
1000            "cs-test:1",
1001            "cs-test:000000",
1002            "cs-test:00000a",
1003            "cs-test:1000000",
1004        ] {
1005            let error = GeneratedCycleId::parse(raw).err();
1006            assert!(error.is_some(), "`{raw}` must be refused");
1007        }
1008    }
1009
1010    #[test]
1011    fn a_partial_identity_on_a_record_is_refused_rather_than_half_read() {
1012        let record = CycleRecord {
1013            name: "Sprint 1".into(),
1014            sequence: Some(1),
1015            ..CycleRecord::default()
1016        };
1017        let error =
1018            GeneratedCycle::from_record(&record).expect_err("a partial identity is refused");
1019        assert!(error.to_string().contains("written together"), "{error}");
1020
1021        let legacy = CycleRecord {
1022            name: "Cycle 1".into(),
1023            ..CycleRecord::default()
1024        };
1025        assert!(GeneratedCycle::from_record(&legacy).unwrap().is_none());
1026    }
1027}