Skip to main content

tix/recurrence/
series.rs

1//! Stable series identity, lifecycle metadata, and the occurrence-key index
2//! (mission `recurring-issues-01M3C7G7`, WP02; `data-model.md` §1/§3/§4 and
3//! `contracts/series-generator.md`).
4//!
5//! # Identity is the pair, never the label
6//!
7//! A series is identified by its **stable id**, and one scheduled occurrence by
8//! the pair (**series id**, canonical date key). The display name, the issue
9//! number and the title slug are none of them identity: a rename or a renumber
10//! must not change what an occurrence *is*, or the next detection would generate
11//! it again. C-002 says the same in one line, and [`OccurrenceKey`] is that
12//! sentence as a type.
13//!
14//! # Where the data lives
15//!
16//! Both roles are one table in issue frontmatter — `series:` — so the series
17//! definition **is** an issue (C-001: Markdown is canonical; there is no queue
18//! database and no daemon to own it). Which role a table carries is decided by
19//! its fields, not by a flag a writer could set inconsistently:
20//!
21//! * a **definition** carries `rule` (and `name`, `state`, `next_cursor`,
22//!   `skipped`);
23//! * an **occurrence** carries the `key` and `date` it was generated for.
24//!
25//! # This module indexes; it does not write
26//!
27//! [`SeriesIndex`] is built from issues the caller already read and answers three
28//! questions: which series exist, which occurrence keys are already taken
29//! (generated **or** skipped), and whether the data is internally consistent. It
30//! reports every finding instead of failing on the first, because a repository
31//! with one malformed series must still list the others.
32//!
33//! Generation, detection and lifecycle transitions are **not** here: WP03 owns
34//! read-only detection, WP04 the confirmed generator, WP05 the lifecycle. The one
35//! thing this module refuses on behalf of all of them is a **cycle**. The only
36//! reference this model forms is occurrence → series, so the only cycle is a
37//! definition that is its own occurrence, reported as
38//! [`SeriesDiagnosticKind::SeriesIsItsOwnOccurrence`].
39
40use std::collections::{BTreeMap, BTreeSet};
41use std::fmt;
42
43use serde::{Deserialize, Serialize};
44use time::{Date, Month};
45
46use crate::error::render_diagnostics;
47use crate::model::{Issue, SeriesRef, SeriesRule};
48use crate::recurrence::rule::{DateKey, IntervalUnit, RecurrenceRule, RuleKind, Timezone};
49
50/// Which role a `series:` table plays.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub enum SeriesRole {
53    /// The issue *is* the recurring definition: it carries the rule.
54    Definition,
55    /// The issue was generated from a series: it carries an occurrence key.
56    Occurrence,
57}
58
59impl SeriesRef {
60    /// The role this table plays, decided by its fields (see the module docs).
61    ///
62    /// A table with a `rule` is a definition; anything else is an occurrence — a
63    /// table with neither is an occurrence missing its key, which
64    /// [`SeriesIndex::build`] reports rather than guessing at.
65    pub fn role(&self) -> SeriesRole {
66        if self.rule.is_some() {
67            SeriesRole::Definition
68        } else {
69            SeriesRole::Occurrence
70        }
71    }
72}
73
74/// The lifecycle state of a series (`data-model.md` §4).
75///
76/// `Ended` is terminal for generation; `Paused` suspends it until an explicit
77/// resume. Neither reaches back into the occurrences already generated: an
78/// occurrence is an independent issue (C-005).
79#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
80#[serde(rename_all = "kebab-case")]
81pub enum SeriesLifecycle {
82    /// Generating occurrences is allowed.
83    #[default]
84    Active,
85    /// Generating is suspended until an explicit resume.
86    Paused,
87    /// Terminal: no further occurrence is generated.
88    Ended,
89}
90
91impl SeriesLifecycle {
92    /// The persisted spelling, which is also what the CLI prints.
93    pub const fn as_str(self) -> &'static str {
94        match self {
95            Self::Active => "active",
96            Self::Paused => "paused",
97            Self::Ended => "ended",
98        }
99    }
100
101    /// Whether this state allows a new occurrence to be generated. Only `Active`
102    /// does; `Paused` is a suspension and `Ended` a terminus.
103    pub const fn allows_generation(self) -> bool {
104        matches!(self, Self::Active)
105    }
106}
107
108/// A series id: a stable, non-empty token.
109///
110/// The shape is deliberately strict — ASCII letters, digits, `-`, `_`, `.` —
111/// because the id is embedded in a derived occurrence key that is parsed back out
112/// of frontmatter. An id that needed quoting would be a second grammar in a field
113/// whose whole point is that it needs none. `SeriesId::generate` mints `s-` plus
114/// eight Crockford base32 characters, the same alphabet [`crate::id`] gives
115/// issues.
116#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
117#[serde(transparent)]
118pub struct SeriesId(String);
119
120impl SeriesId {
121    /// Validate `raw` as a series id.
122    pub fn parse(raw: &str) -> Result<Self, SeriesError> {
123        let trimmed = raw.trim();
124        if trimmed.is_empty() {
125            return Err(SeriesError::single(SeriesDiagnostic::new(
126                SeriesDiagnosticKind::InvalidSeriesId,
127                "series.id",
128                "a series id cannot be empty",
129            )));
130        }
131        if !trimmed
132            .bytes()
133            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
134        {
135            return Err(SeriesError::single(
136                SeriesDiagnostic::new(
137                    SeriesDiagnosticKind::InvalidSeriesId,
138                    "series.id",
139                    "a series id may hold only ASCII letters, digits, `-`, `_` and `.`",
140                )
141                .with_value(raw),
142            ));
143        }
144        Ok(Self(trimmed.to_string()))
145    }
146
147    /// Mint a fresh id: `s-` plus eight Crockford base32 characters.
148    pub fn generate() -> Self {
149        Self(format!("s-{}", crate::id::generate_issue_id()))
150    }
151
152    /// The id as a string.
153    pub fn as_str(&self) -> &str {
154        &self.0
155    }
156}
157
158impl fmt::Display for SeriesId {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        f.write_str(&self.0)
161    }
162}
163
164/// One scheduled occurrence's stable identity (`data-model.md` §3).
165///
166/// The key is `<series-id>@<YYYY-MM-DD>`: the same series and the same canonical
167/// date always produce the same key, so a second generation attempt is a lookup
168/// and never a duplicate. It is **stored** (in `series.key`) rather than
169/// re-derived, so a file whose key the rule would no longer produce is still
170/// readable — and still a no-op (C-006).
171#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
172#[serde(transparent)]
173pub struct OccurrenceKey(String);
174
175impl OccurrenceKey {
176    /// The key for `series` on `date`.
177    pub fn new(series: &SeriesId, date: &DateKey) -> Self {
178        Self(format!("{}@{}", series.as_str(), date.as_str()))
179    }
180
181    /// The key as a string, as `series.key` stores it.
182    pub fn as_str(&self) -> &str {
183        &self.0
184    }
185
186    /// Parse a stored key back into its two parts.
187    ///
188    /// The separator is `@`, which no series id and no date key can contain — the
189    /// id is validated above and a date key is exactly `YYYY-MM-DD`.
190    pub fn parse(raw: &str) -> Result<(SeriesId, DateKey), SeriesError> {
191        let Some((id, date)) = raw.split_once('@') else {
192            return Err(SeriesError::single(
193                SeriesDiagnostic::new(
194                    SeriesDiagnosticKind::MalformedKey,
195                    "series.key",
196                    "a key is `<series-id>@<YYYY-MM-DD>`",
197                )
198                .with_value(raw),
199            ));
200        };
201        let id = SeriesId::parse(id)?;
202        let date = parse_date_key(date).ok_or_else(|| {
203            SeriesError::single(
204                SeriesDiagnostic::new(
205                    SeriesDiagnosticKind::MalformedKey,
206                    "series.key",
207                    "the date half must be a real `YYYY-MM-DD` calendar date",
208                )
209                .with_value(raw),
210            )
211        })?;
212        Ok((id, date))
213    }
214}
215
216impl fmt::Display for OccurrenceKey {
217    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218        f.write_str(&self.0)
219    }
220}
221
222/// A series definition, as its issue carries it (`data-model.md` §1 + §4).
223///
224/// Built from the frontmatter by [`Series::from_ref`]. The defining issue's number
225/// and filename are carried for diagnostics and listing, never for identity.
226#[derive(Debug, Clone, PartialEq)]
227pub struct Series {
228    /// The stable id.
229    pub id: SeriesId,
230    /// The mutable display name.
231    pub name: String,
232    /// The validated rule.
233    pub rule: RecurrenceRule,
234    /// The lifecycle state.
235    pub state: SeriesLifecycle,
236    /// The generation cursor, when one is recorded.
237    pub next_cursor: Option<DateKey>,
238    /// Occurrence dates that were skipped, so generation never recreates them.
239    pub skipped: BTreeSet<DateKey>,
240    /// The issue that defines this series.
241    pub issue_number: u32,
242    /// The defining issue's filename, for diagnostics.
243    pub filename: String,
244}
245
246impl Series {
247    /// Read a definition out of a `series:` table.
248    ///
249    /// `Ok(None)` for a table that is an occurrence (no `rule`), so a caller can
250    /// map over a whole corpus without classifying it first. Every field a
251    /// definition needs is checked here: an unusable id, a missing name, and a
252    /// rule that does not validate are field-aware diagnostics, and nothing is
253    /// defaulted silently.
254    pub fn from_ref(
255        reference: &SeriesRef,
256        issue_number: u32,
257        filename: &str,
258    ) -> Result<Option<Self>, SeriesError> {
259        if reference.role() != SeriesRole::Definition {
260            return Ok(None);
261        }
262        let mut diagnostics: Vec<SeriesDiagnostic> = Vec::new();
263        let id = match SeriesId::parse(&reference.id) {
264            Ok(id) => Some(id),
265            Err(error) => {
266                diagnostics.extend(error.diagnostics().iter().cloned());
267                None
268            }
269        };
270        let name = reference.name.clone().unwrap_or_default();
271        if name.trim().is_empty() {
272            diagnostics.push(SeriesDiagnostic::new(
273                SeriesDiagnosticKind::MissingField,
274                "series.name",
275                "a series definition needs a display name (`series.name`)",
276            ));
277        }
278        let stored = reference
279            .rule
280            .as_ref()
281            .expect("`role()` says a rule is set");
282        let rule = match typed_rule(stored) {
283            Ok(rule) => Some(rule),
284            Err(error) => {
285                diagnostics.extend(error.diagnostics().iter().cloned());
286                None
287            }
288        };
289        if let Some(rule) = rule.as_ref()
290            && let Err(error) = rule.validate()
291        {
292            diagnostics.extend(error.diagnostics().iter().map(|diagnostic| {
293                SeriesDiagnostic::new(
294                    SeriesDiagnosticKind::InvalidRule,
295                    format!("series.rule.{}", diagnostic.field),
296                    diagnostic.message.clone(),
297                )
298            }));
299        }
300        if !diagnostics.is_empty() {
301            return Err(SeriesError::new(diagnostics));
302        }
303        Ok(Some(Self {
304            id: id.expect("an unusable id is a diagnostic"),
305            name,
306            rule: rule.expect("a rule that does not build is a diagnostic"),
307            state: reference.state.unwrap_or_default(),
308            next_cursor: reference.next_cursor.as_deref().and_then(parse_date_key),
309            skipped: reference
310                .skipped
311                .iter()
312                .filter_map(|raw| parse_date_key(raw))
313                .collect(),
314            issue_number,
315            filename: filename.to_string(),
316        }))
317    }
318
319    /// Whether this series may generate a new occurrence right now.
320    pub const fn allows_generation(&self) -> bool {
321        self.state.allows_generation()
322    }
323
324    /// The occurrence key for `date` under this series.
325    pub fn key_for(&self, date: &DateKey) -> OccurrenceKey {
326        OccurrenceKey::new(&self.id, date)
327    }
328
329    /// Whether `date` was explicitly skipped, so generation must not recreate it.
330    pub fn is_skipped(&self, date: &DateKey) -> bool {
331        self.skipped.contains(date)
332    }
333}
334
335/// One occurrence, as the index resolved it from the issues it was built over.
336#[derive(Debug, Clone, PartialEq)]
337pub struct OccurrenceRecord {
338    /// The series it belongs to.
339    pub series_id: SeriesId,
340    /// The canonical date it was generated for.
341    pub date: DateKey,
342    /// Its idempotency key.
343    pub key: OccurrenceKey,
344    /// The issue that carries it.
345    pub issue_number: u32,
346    /// That issue's filename, for diagnostics.
347    pub filename: String,
348}
349
350/// What the index can say about a key.
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
352pub enum KeyState {
353    /// An occurrence issue exists for it.
354    Generated,
355    /// The series recorded the date as skipped.
356    Skipped,
357    /// Neither: the key is free to generate.
358    Free,
359}
360
361/// The kinds of inconsistency the index reports.
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
363pub enum SeriesDiagnosticKind {
364    /// A series id is not a usable token.
365    InvalidSeriesId,
366    /// Two issues define the same series id.
367    DuplicateSeriesId,
368    /// A definition is missing a field the model requires.
369    MissingField,
370    /// The rule itself is invalid; the diagnostic carries the rule's own message.
371    InvalidRule,
372    /// A `series.key` is not `<id>@<date>`.
373    MalformedKey,
374    /// `series.id` and the id half of `series.key` disagree.
375    KeySeriesMismatch,
376    /// `series.date` and the date half of `series.key` disagree.
377    KeyDateMismatch,
378    /// An occurrence carries no key, so it could never be recognised again.
379    MissingOccurrenceKey,
380    /// Two issues claim the same occurrence key.
381    DuplicateOccurrenceKey,
382    /// A definition is also an occurrence of itself — this model's only cycle.
383    SeriesIsItsOwnOccurrence,
384}
385
386impl SeriesDiagnosticKind {
387    /// The stable spelling `tix lint` and the CLI print.
388    pub const fn as_str(self) -> &'static str {
389        match self {
390            Self::InvalidSeriesId => "invalid_series_id",
391            Self::DuplicateSeriesId => "duplicate_series_id",
392            Self::MissingField => "missing_series_field",
393            Self::InvalidRule => "invalid_series_rule",
394            Self::MalformedKey => "malformed_occurrence_key",
395            Self::KeySeriesMismatch => "occurrence_key_series_mismatch",
396            Self::KeyDateMismatch => "occurrence_key_date_mismatch",
397            Self::MissingOccurrenceKey => "missing_occurrence_key",
398            Self::DuplicateOccurrenceKey => "duplicate_occurrence_key",
399            Self::SeriesIsItsOwnOccurrence => "series_is_its_own_occurrence",
400        }
401    }
402}
403
404/// One actionable finding, naming the issue and the field it is about.
405#[derive(Debug, Clone, PartialEq, Eq)]
406pub struct SeriesDiagnostic {
407    /// What is wrong.
408    pub kind: SeriesDiagnosticKind,
409    /// The issue the finding is about; `0` before it is attached to one.
410    pub issue_number: u32,
411    /// The issue's filename, empty before it is attached to one.
412    pub filename: String,
413    /// The field path, e.g. `series.key`.
414    pub field: String,
415    /// One sentence saying what to do about it.
416    pub message: String,
417}
418
419impl SeriesDiagnostic {
420    /// A finding about `field`, before it is attached to an issue.
421    pub fn new(
422        kind: SeriesDiagnosticKind,
423        field: impl Into<String>,
424        message: impl Into<String>,
425    ) -> Self {
426        Self {
427            kind,
428            issue_number: 0,
429            filename: String::new(),
430            field: field.into(),
431            message: message.into(),
432        }
433    }
434
435    /// Name the offending value in the message.
436    pub fn with_value(mut self, raw: &str) -> Self {
437        self.message = format!("{} (got `{raw}`)", self.message);
438        self
439    }
440
441    /// Attach the finding to the issue it came from.
442    pub fn on(mut self, issue_number: u32, filename: &str) -> Self {
443        self.issue_number = issue_number;
444        self.filename = filename.to_string();
445        self
446    }
447
448    /// Render through the crate's one diagnostic renderer, so the CLI, the TUI,
449    /// MCP and `tix lint` spell a refusal the same way.
450    pub fn render(&self) -> String {
451        render_diagnostics(
452            "E-tix-series",
453            std::iter::once((self.kind.as_str(), self.message.as_str())),
454        )
455    }
456}
457
458/// A set of findings, reported together.
459#[derive(Debug, Clone, PartialEq, Eq)]
460pub struct SeriesError {
461    diagnostics: Vec<SeriesDiagnostic>,
462}
463
464impl SeriesError {
465    /// Wrap the findings.
466    pub fn new(diagnostics: Vec<SeriesDiagnostic>) -> Self {
467        Self { diagnostics }
468    }
469
470    /// A single finding.
471    pub fn single(diagnostic: SeriesDiagnostic) -> Self {
472        Self {
473            diagnostics: vec![diagnostic],
474        }
475    }
476
477    /// Every finding, in the order produced.
478    pub fn diagnostics(&self) -> &[SeriesDiagnostic] {
479        &self.diagnostics
480    }
481
482    /// The first finding, for a caller that can only show one.
483    pub fn first(&self) -> &SeriesDiagnostic {
484        &self.diagnostics[0]
485    }
486}
487
488impl fmt::Display for SeriesError {
489    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
490        f.write_str(&self.first().message)
491    }
492}
493
494/// The series and occurrence picture over a set of issues.
495///
496/// One pass over issues the caller already holds: no I/O, no clock, no writes.
497#[derive(Debug, Clone, Default, PartialEq)]
498pub struct SeriesIndex {
499    series: BTreeMap<SeriesId, Series>,
500    occurrences: BTreeMap<OccurrenceKey, OccurrenceRecord>,
501    diagnostics: Vec<SeriesDiagnostic>,
502}
503
504impl SeriesIndex {
505    /// Index `issues`.
506    ///
507    /// Every issue is examined once: a definition is parsed and validated, an
508    /// occurrence is parsed into its key, and anything inconsistent becomes a
509    /// diagnostic while the rest of the index stays usable.
510    pub fn build(issues: &[Issue]) -> Self {
511        let mut index = Self::default();
512        for issue in issues {
513            if let Some(reference) = issue.series.as_ref() {
514                index.absorb(issue, reference);
515            }
516        }
517        index.check_cycles(issues);
518        index
519    }
520
521    fn absorb(&mut self, issue: &Issue, reference: &SeriesRef) {
522        match reference.role() {
523            SeriesRole::Definition => self.absorb_definition(issue, reference),
524            SeriesRole::Occurrence => self.absorb_occurrence(issue, reference),
525        }
526    }
527
528    fn absorb_definition(&mut self, issue: &Issue, reference: &SeriesRef) {
529        match Series::from_ref(reference, issue.number, &issue.filename) {
530            Ok(Some(series)) => {
531                if let Some(previous) = self.series.get(&series.id) {
532                    self.diagnostics.push(
533                        SeriesDiagnostic::new(
534                            SeriesDiagnosticKind::DuplicateSeriesId,
535                            "series.id",
536                            format!(
537                                "series `{}` is already defined by {}; two issues cannot share \
538                                 one series id",
539                                series.id, previous.filename
540                            ),
541                        )
542                        .on(issue.number, &issue.filename),
543                    );
544                    return;
545                }
546                self.series.insert(series.id.clone(), series);
547            }
548            Ok(None) => {}
549            Err(error) => self.diagnostics.extend(
550                error
551                    .diagnostics()
552                    .iter()
553                    .cloned()
554                    .map(|diagnostic| diagnostic.on(issue.number, &issue.filename)),
555            ),
556        }
557    }
558
559    fn absorb_occurrence(&mut self, issue: &Issue, reference: &SeriesRef) {
560        let Some(raw_key) = reference.key.as_deref() else {
561            self.diagnostics.push(
562                SeriesDiagnostic::new(
563                    SeriesDiagnosticKind::MissingOccurrenceKey,
564                    "series.key",
565                    "an occurrence needs the key it was generated for \
566                     (`<series-id>@<YYYY-MM-DD>`)",
567                )
568                .on(issue.number, &issue.filename),
569            );
570            return;
571        };
572        let (series_id, date) = match OccurrenceKey::parse(raw_key) {
573            Ok(parts) => parts,
574            Err(error) => {
575                self.diagnostics.extend(
576                    error
577                        .diagnostics()
578                        .iter()
579                        .cloned()
580                        .map(|diagnostic| diagnostic.on(issue.number, &issue.filename)),
581                );
582                return;
583            }
584        };
585        if series_id.as_str() != reference.id {
586            self.diagnostics.push(
587                SeriesDiagnostic::new(
588                    SeriesDiagnosticKind::KeySeriesMismatch,
589                    "series.key",
590                    format!(
591                        "the key names series `{series_id}` but `series.id` is `{}`",
592                        reference.id
593                    ),
594                )
595                .on(issue.number, &issue.filename),
596            );
597        }
598        if let Some(declared) = reference.date.as_deref()
599            && declared != date.as_str()
600        {
601            self.diagnostics.push(
602                SeriesDiagnostic::new(
603                    SeriesDiagnosticKind::KeyDateMismatch,
604                    "series.date",
605                    format!("`series.date` is `{declared}` but the key names `{date}`"),
606                )
607                .on(issue.number, &issue.filename),
608            );
609        }
610        let key = OccurrenceKey::new(&series_id, &date);
611        if let Some(previous) = self.occurrences.get(&key) {
612            self.diagnostics.push(
613                SeriesDiagnostic::new(
614                    SeriesDiagnosticKind::DuplicateOccurrenceKey,
615                    "series.key",
616                    format!(
617                        "occurrence `{key}` already belongs to {}; one key is one occurrence",
618                        previous.filename
619                    ),
620                )
621                .on(issue.number, &issue.filename),
622            );
623            return;
624        }
625        self.occurrences.insert(
626            key.clone(),
627            OccurrenceRecord {
628                series_id,
629                date,
630                key,
631                issue_number: issue.number,
632                filename: issue.filename.clone(),
633            },
634        );
635    }
636
637    /// The model's only possible cycle: an issue that is both a definition and an
638    /// occurrence. The reference graph is occurrence → series, so a cycle needs one
639    /// issue to sit on both sides of that edge — and while it does, "generate the
640    /// next occurrence" could target the definition itself.
641    ///
642    /// A `key` on a definition is the whole test: `role()` says definition because
643    /// the rule is set, and the key says the same issue is also an occurrence.
644    fn check_cycles(&mut self, issues: &[Issue]) {
645        for issue in issues {
646            let Some(reference) = issue.series.as_ref() else {
647                continue;
648            };
649            if reference.role() != SeriesRole::Definition {
650                continue;
651            }
652            let Some(raw_key) = reference.key.as_deref() else {
653                continue;
654            };
655            self.diagnostics.push(
656                SeriesDiagnostic::new(
657                    SeriesDiagnosticKind::SeriesIsItsOwnOccurrence,
658                    "series.key",
659                    format!(
660                        "issue {} defines series `{}` and also carries the occurrence key \
661                         `{raw_key}`; a definition cannot be an occurrence of the series it \
662                         would generate",
663                        issue.number, reference.id
664                    ),
665                )
666                .on(issue.number, &issue.filename),
667            );
668        }
669    }
670
671    /// The definitions, in id order.
672    pub fn series(&self) -> impl Iterator<Item = &Series> {
673        self.series.values()
674    }
675
676    /// One definition by id.
677    pub fn series_by_id(&self, id: &SeriesId) -> Option<&Series> {
678        self.series.get(id)
679    }
680
681    /// How many definitions the index holds.
682    pub fn series_count(&self) -> usize {
683        self.series.len()
684    }
685
686    /// One occurrence by key.
687    pub fn occurrence(&self, key: &OccurrenceKey) -> Option<&OccurrenceRecord> {
688        self.occurrences.get(key)
689    }
690
691    /// Every occurrence, in key order — chronological per series, and stable
692    /// across runs.
693    pub fn occurrences(&self) -> impl Iterator<Item = &OccurrenceRecord> {
694        self.occurrences.values()
695    }
696
697    /// What the index knows about `key`.
698    pub fn key_state(&self, key: &OccurrenceKey) -> KeyState {
699        if self.occurrences.contains_key(key) {
700            return KeyState::Generated;
701        }
702        if let Ok((id, date)) = OccurrenceKey::parse(key.as_str())
703            && self
704                .series
705                .get(&id)
706                .is_some_and(|series| series.is_skipped(&date))
707        {
708            return KeyState::Skipped;
709        }
710        KeyState::Free
711    }
712
713    /// Whether `key` is taken — generated **or** explicitly skipped, which is what
714    /// keeps a skipped date from being generated the next time detection runs
715    /// (C-006).
716    pub fn is_taken(&self, key: &OccurrenceKey) -> bool {
717        self.key_state(key) != KeyState::Free
718    }
719
720    /// Every finding, in the order the issues were visited.
721    pub fn diagnostics(&self) -> &[SeriesDiagnostic] {
722        &self.diagnostics
723    }
724
725    /// Whether the index is internally consistent.
726    pub fn is_consistent(&self) -> bool {
727        self.diagnostics.is_empty()
728    }
729}
730
731/// Build the typed rule from the shape the file stores (`data-model.md` §2).
732///
733/// Every named field is read from the stored table and the result is validated by
734/// the rule's own `validate`, so a series whose rule is wrong is reported with the
735/// rule's field-aware diagnostics and not with a type error. An unknown cadence
736/// key is carried through: the stored shape is flat, like every other preserved
737/// metadata area in this crate, and it is the model that keeps the file's own
738/// table — this value is for validation and arithmetic.
739fn typed_rule(stored: &SeriesRule) -> Result<RecurrenceRule, SeriesError> {
740    let mut diagnostics = Vec::new();
741    let kind = match stored.kind.as_deref().map(str::trim) {
742        Some(raw) => match parse_kind(raw) {
743            Some(kind) => Some(kind),
744            None => {
745                diagnostics.push(
746                    SeriesDiagnostic::new(
747                        SeriesDiagnosticKind::InvalidRule,
748                        "series.rule.kind",
749                        "the rule kind must be one of `daily`, `weekly`, `monthly`,                          `interval`, `cron-like`",
750                    )
751                    .with_value(raw),
752                );
753                None
754            }
755        },
756        None => {
757            diagnostics.push(SeriesDiagnostic::new(
758                SeriesDiagnosticKind::InvalidRule,
759                "series.rule.kind",
760                "a series rule needs a kind (`series.rule.kind`)",
761            ));
762            None
763        }
764    };
765    let unit = match stored.unit.as_deref().map(str::trim) {
766        Some(raw) => match parse_unit(raw) {
767            Some(unit) => Some(unit),
768            None => {
769                diagnostics.push(
770                    SeriesDiagnostic::new(
771                        SeriesDiagnosticKind::InvalidRule,
772                        "series.rule.unit",
773                        "the unit must be one of `days`, `weeks`, `months`",
774                    )
775                    .with_value(raw),
776                );
777                None
778            }
779        },
780        None => None,
781    };
782    if !diagnostics.is_empty() {
783        return Err(SeriesError::new(diagnostics));
784    }
785    Ok(RecurrenceRule {
786        kind: kind.expect("a missing kind is a diagnostic"),
787        interval: stored.interval,
788        anchor: stored.anchor.clone(),
789        timezone: stored.timezone.as_deref().map(Timezone::parse),
790        expression: stored.expression.clone(),
791        unit,
792        extensions: stored
793            .extensions
794            .iter()
795            .map(|(key, value)| (key.clone(), serde_yaml::Value::String(value.clone())))
796            .collect(),
797    })
798}
799
800/// The family a stored kind names, or `None` when it names one this build does
801/// not support. The spellings are the contract's and match `RuleKind::as_str`.
802fn parse_kind(raw: &str) -> Option<RuleKind> {
803    match raw {
804        "daily" => Some(RuleKind::Daily),
805        "weekly" => Some(RuleKind::Weekly),
806        "monthly" => Some(RuleKind::Monthly),
807        "interval" => Some(RuleKind::Interval),
808        "cron-like" => Some(RuleKind::CronLike),
809        _ => None,
810    }
811}
812
813/// The unit a stored spelling names, or `None` when it names one this build does
814/// not support. Matches `IntervalUnit::as_str`.
815fn parse_unit(raw: &str) -> Option<IntervalUnit> {
816    match raw {
817        "days" => Some(IntervalUnit::Days),
818        "weeks" => Some(IntervalUnit::Weeks),
819        "months" => Some(IntervalUnit::Months),
820        _ => None,
821    }
822}
823
824/// Parse a canonical `YYYY-MM-DD` date key.
825///
826/// The calendar authority is [`crate::model::validate_date`] — the same check
827/// every other stored date passes — so a series date cannot be "a date" to one
828/// part of the crate and not another; `time` provides the value.
829fn parse_date_key(raw: &str) -> Option<DateKey> {
830    if !crate::model::validate_date(raw) {
831        return None;
832    }
833    let year: i32 = raw.get(0..4)?.parse().ok()?;
834    let month: u8 = raw.get(5..7)?.parse().ok()?;
835    let day: u8 = raw.get(8..10)?.parse().ok()?;
836    let date = Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()?;
837    Some(DateKey::from_date(date))
838}
839
840#[cfg(test)]
841mod tests {
842    #[allow(clippy::wildcard_imports)]
843    use super::*;
844
845    /// An issue from a frontmatter block, through the real parser: the `series:`
846    /// contract is the file format, so the fixtures use the file format.
847    fn issue(number: u32, filename: &str, series: &str) -> Issue {
848        let contents = format!(
849            "---\nnumber: {number}\ntitle: \"Fixture {number}\"\nstatus: \"todo\"\n\
850             created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n{series}---\n\nBody.\n"
851        );
852        let mut issue = Issue::parse(&contents).expect("the fixture parses");
853        issue.filename = filename.to_string();
854        issue
855    }
856
857    fn date(raw: &str) -> DateKey {
858        parse_date_key(raw).expect("a real date")
859    }
860
861    /// The key a lookup uses: parsed and re-derived, so the test exercises the
862    /// parse path and the identity at once.
863    fn key(raw: &str) -> OccurrenceKey {
864        let (id, date) = OccurrenceKey::parse(raw).expect("a well-formed key");
865        OccurrenceKey::new(&id, &date)
866    }
867
868    fn definition(number: u32, filename: &str, id: &str, extra: &str) -> Issue {
869        issue(
870            number,
871            filename,
872            &format!(
873                "series:\n  id: {id}\n  name: Nightly\n  rule:\n    kind: daily\n    \
874                 anchor: 2026-01-01\n{extra}"
875            ),
876        )
877    }
878
879    fn occurrence(number: u32, filename: &str, id: &str, key: &str, date: &str) -> Issue {
880        issue(
881            number,
882            filename,
883            &format!("series:\n  id: {id}\n  date: {date}\n  key: {key}\n"),
884        )
885    }
886
887    #[test]
888    fn a_key_is_the_series_and_the_date() {
889        let id = SeriesId::parse("s-abc").unwrap();
890        let key = OccurrenceKey::new(&id, &date("2026-02-28"));
891        assert_eq!(key.as_str(), "s-abc@2026-02-28");
892        let (parsed_id, parsed_date) = OccurrenceKey::parse(key.as_str()).unwrap();
893        assert_eq!(parsed_id, id);
894        assert_eq!(parsed_date, date("2026-02-28"));
895    }
896
897    #[test]
898    fn a_malformed_key_names_what_a_key_is() {
899        assert_eq!(
900            OccurrenceKey::parse("no-separator")
901                .unwrap_err()
902                .first()
903                .kind,
904            SeriesDiagnosticKind::MalformedKey
905        );
906        // `2026-02-30` is not a calendar date, so the date half is refused.
907        assert_eq!(
908            OccurrenceKey::parse("s-abc@2026-02-30")
909                .unwrap_err()
910                .first()
911                .kind,
912            SeriesDiagnosticKind::MalformedKey
913        );
914    }
915
916    #[test]
917    fn an_unsafe_id_is_refused() {
918        assert_eq!(
919            SeriesId::parse("").unwrap_err().first().kind,
920            SeriesDiagnosticKind::InvalidSeriesId
921        );
922        assert_eq!(
923            SeriesId::parse("s abc").unwrap_err().first().kind,
924            SeriesDiagnosticKind::InvalidSeriesId
925        );
926        assert_eq!(SeriesId::parse("s-abc_1.2").unwrap().as_str(), "s-abc_1.2");
927        assert!(SeriesId::generate().as_str().starts_with("s-"));
928    }
929
930    #[test]
931    fn the_index_separates_definitions_from_occurrences() {
932        let issues = vec![
933            definition(1, "nightly.md", "s-abc", ""),
934            occurrence(2, "a.md", "s-abc", "s-abc@2026-01-01", "2026-01-01"),
935        ];
936        let index = SeriesIndex::build(&issues);
937        assert!(index.is_consistent(), "{:?}", index.diagnostics());
938        assert_eq!(index.series_count(), 1);
939        assert_eq!(index.occurrences().count(), 1);
940        assert_eq!(
941            index.key_state(&key("s-abc@2026-01-01")),
942            KeyState::Generated
943        );
944        assert_eq!(index.key_state(&key("s-abc@2026-01-02")), KeyState::Free);
945    }
946
947    #[test]
948    fn a_duplicate_series_id_is_reported_and_the_first_definition_wins() {
949        let issues = vec![
950            definition(1, "first.md", "s-abc", ""),
951            definition(2, "second.md", "s-abc", ""),
952        ];
953        let index = SeriesIndex::build(&issues);
954        assert_eq!(index.series_count(), 1);
955        assert_eq!(
956            index.diagnostics()[0].kind,
957            SeriesDiagnosticKind::DuplicateSeriesId
958        );
959        assert_eq!(
960            index
961                .series_by_id(&SeriesId::parse("s-abc").unwrap())
962                .unwrap()
963                .issue_number,
964            1
965        );
966    }
967
968    #[test]
969    fn a_key_that_contradicts_its_declared_series_or_date_is_reported() {
970        let issues = vec![
971            definition(1, "nightly.md", "s-abc", ""),
972            occurrence(2, "a.md", "s-abc", "s-xyz@2026-01-01", "2026-01-01"),
973            occurrence(3, "b.md", "s-abc", "s-abc@2026-01-02", "2026-01-03"),
974        ];
975        let index = SeriesIndex::build(&issues);
976        let kinds: Vec<_> = index.diagnostics().iter().map(|d| d.kind).collect();
977        assert!(
978            kinds.contains(&SeriesDiagnosticKind::KeySeriesMismatch),
979            "{kinds:?}"
980        );
981        assert!(
982            kinds.contains(&SeriesDiagnosticKind::KeyDateMismatch),
983            "{kinds:?}"
984        );
985    }
986
987    #[test]
988    fn an_occurrence_without_a_key_is_reported_rather_than_guessed() {
989        let broken = issue(2, "a.md", "series:\n  id: s-abc\n  date: 2026-01-01\n");
990        let index = SeriesIndex::build(&[broken]);
991        assert_eq!(
992            index.diagnostics()[0].kind,
993            SeriesDiagnosticKind::MissingOccurrenceKey
994        );
995    }
996
997    #[test]
998    fn two_issues_cannot_share_one_occurrence_key() {
999        let issues = vec![
1000            occurrence(2, "a.md", "s-abc", "s-abc@2026-01-01", "2026-01-01"),
1001            occurrence(3, "b.md", "s-abc", "s-abc@2026-01-01", "2026-01-01"),
1002        ];
1003        let index = SeriesIndex::build(&issues);
1004        assert_eq!(index.occurrences().count(), 1);
1005        assert_eq!(
1006            index.diagnostics()[0].kind,
1007            SeriesDiagnosticKind::DuplicateOccurrenceKey
1008        );
1009    }
1010
1011    #[test]
1012    fn a_definition_that_is_its_own_occurrence_is_the_model_s_only_cycle() {
1013        let mut own = definition(1, "nightly.md", "s-abc", "");
1014        if let Some(reference) = own.series.as_mut() {
1015            reference.key = Some("s-abc@2026-01-01".to_string());
1016            reference.date = Some("2026-01-01".to_string());
1017        }
1018        let index = SeriesIndex::build(&[own]);
1019        assert!(
1020            index
1021                .diagnostics()
1022                .iter()
1023                .any(|d| d.kind == SeriesDiagnosticKind::SeriesIsItsOwnOccurrence),
1024            "{:?}",
1025            index.diagnostics()
1026        );
1027    }
1028
1029    #[test]
1030    fn a_skipped_date_is_taken_and_never_free() {
1031        let series = definition(1, "nightly.md", "s-abc", "  skipped:\n  - 2026-01-02\n");
1032        let index = SeriesIndex::build(&[series]);
1033        let skipped = key("s-abc@2026-01-02");
1034        assert_eq!(index.key_state(&skipped), KeyState::Skipped);
1035        assert!(index.is_taken(&skipped), "a skipped key is not free");
1036        assert!(!index.is_taken(&key("s-abc@2026-01-03")));
1037    }
1038
1039    #[test]
1040    fn only_active_allows_generation() {
1041        let ended = definition(1, "nightly.md", "s-abc", "  state: ended\n");
1042        let index = SeriesIndex::build(&[ended]);
1043        let stored = index
1044            .series_by_id(&SeriesId::parse("s-abc").unwrap())
1045            .unwrap();
1046        assert_eq!(stored.state, SeriesLifecycle::Ended);
1047        assert!(!stored.allows_generation());
1048        assert!(!SeriesLifecycle::Paused.allows_generation());
1049        assert!(SeriesLifecycle::Active.allows_generation());
1050    }
1051
1052    #[test]
1053    fn identity_is_the_key_not_the_issue_number_or_the_title() {
1054        // Same id, same date, different issue numbers and titles: one key, so the
1055        // collision is reported against the *key*.
1056        let first = occurrence(7, "a.md", "s-abc", "s-abc@2026-01-01", "2026-01-01");
1057        let mut renamed = occurrence(99, "b.md", "s-abc", "s-abc@2026-01-01", "2026-01-01");
1058        renamed.title = "Renamed".to_string();
1059        let index = SeriesIndex::build(&[first, renamed]);
1060        assert_eq!(
1061            index.diagnostics()[0].kind,
1062            SeriesDiagnosticKind::DuplicateOccurrenceKey
1063        );
1064        let key = key("s-abc@2026-01-01");
1065        assert_eq!(index.occurrence(&key).unwrap().issue_number, 7);
1066    }
1067
1068    #[test]
1069    fn a_definition_without_a_name_or_with_a_broken_rule_is_reported() {
1070        let unnamed = issue(
1071            1,
1072            "nightly.md",
1073            "series:\n  id: s-abc\n  rule:\n    kind: daily\n    anchor: 2026-01-01\n",
1074        );
1075        let index = SeriesIndex::build(&[unnamed]);
1076        assert_eq!(
1077            index.diagnostics()[0].kind,
1078            SeriesDiagnosticKind::MissingField
1079        );
1080        assert_eq!(index.diagnostics()[0].field, "series.name");
1081
1082        let bad_rule = issue(
1083            2,
1084            "other.md",
1085            "series:\n  id: s-xyz\n  name: Broken\n  rule:\n    kind: interval\n    \
1086             anchor: 2026-01-01\n    interval: 0\n    unit: days\n",
1087        );
1088        let index = SeriesIndex::build(&[bad_rule]);
1089        assert!(
1090            index
1091                .diagnostics()
1092                .iter()
1093                .any(|d| d.kind == SeriesDiagnosticKind::InvalidRule),
1094            "{:?}",
1095            index.diagnostics()
1096        );
1097    }
1098
1099    #[test]
1100    fn a_finding_renders_through_the_shared_renderer() {
1101        let rendered = SeriesDiagnostic::new(
1102            SeriesDiagnosticKind::InvalidSeriesId,
1103            "series.id",
1104            "a series id cannot be empty",
1105        )
1106        .render();
1107        assert!(
1108            rendered.contains("error[E-tix-series]") && rendered.contains("invalid_series_id"),
1109            "{rendered}"
1110        );
1111    }
1112}