Skip to main content

tix/cycle_schedule/
generate.rs

1//! Confirmed, idempotent future-cycle generation (WP04, T018–T023).
2//!
3//! # Only a confirmed action writes, and it writes stable keys
4//!
5//! [`generate`] is the only writer in the cycle-schedule boundary, and it writes
6//! only when the caller says so: [`GenerationRequest::confirmed`] must be set, and
7//! an unconfirmed request is refused before anything is read or written. That is
8//! C-004's "detection is read-only, every mutation is explicit" expressed as a
9//! type rather than a convention — [`crate::cycle_schedule::evaluate`] is handed no
10//! store and has no way to reach this function.
11//!
12//! The unit of work is the **stable key** `(schedule-id, sequence)`, handed out by
13//! [`ScheduleIndex::next_key`] and never renumbered or invented. The requested batch
14//! is the look-ahead the request carries: the next key, then one step of the
15//! schedule's own [`RecurrenceRule`] per further candidate — for a request carrying
16//! the schedule's own `look_ahead`, exactly the candidates
17//! [`crate::cycle_schedule::evaluate`] reports, so a surface generates what its own
18//! warning showed it (C-008). One [`GenerationOutcome`] comes back per requested key:
19//! `created`, `no-op`, or `rejected`.
20//!
21//! # Idempotency is a lookup, and the lookup is durable
22//!
23//! A key that already exists is a **no-op**: nothing is written, nothing is
24//! advanced, and the same bytes stay on disk (C-006/NFR-002). A rejection **ends the
25//! batch** instead of skipping ahead, so generation can never create a sequence
26//! beyond a gap — the same reason a skipped cycle is *recorded* rather than omitted.
27//!
28//! Existence is decided against the records that are on disk *now*. The passed
29//! [`ScheduleIndex`] supplies the key chain — the caller's view of the next
30//! candidate — but an index is a value this very operation invalidates, so a second
31//! call under the same snapshot would otherwise re-create a key it had already
32//! written. The stored records are the authority, which is what makes a repeated
33//! call under the same stable snapshot a no-op rather than a duplicate identity.
34//!
35//! The chain is anchored the same way. Its **start** is the position the records
36//! prove — the authoritative index built inside this call — unless the caller's
37//! snapshot is *behind* it, in which case the caller's earlier position is kept: the
38//! window that snapshot confirmed has already been generated, so its keys report
39//! `no-op` instead of the batch jumping forward past candidates the surface never
40//! showed (C-004), and any key of that window the records do not hold is filled. In
41//! the other direction, a caller whose snapshot is *ahead* of the records — a file
42//! removed since — is pulled back to the records, so generation can never create a
43//! sequence beyond a hole. The steps between candidates are always shared-rule steps.
44//! Neither direction can name an identity twice or skip a sequence.
45//!
46//! # The write order is the contract's, and this is the reason
47//!
48//! A creation is two writes: the **instance record** first (through
49//! [`CycleStore::save_record`], the path a generated cycle takes), then the
50//! **persisted cursor** (through the existing atomic [`Config::save`] seam — no
51//! second seam is added).
52//!
53//! The order is not arbitrary. A crash between the two writes leaves an instance
54//! with a stale cursor, which WP02's index recomputes from the instances and reports
55//! as `cursor_drift`, and which a re-run handles as a `no-op` for that key. The
56//! reverse order would leave a **skipped sequence** — a cursor that has moved past an
57//! instance that does not exist — which nothing can reconstruct, and which C-006
58//! forbids above a duplicate identity. Recoverable staleness beats unrecoverable
59//! loss.
60//!
61//! And because this process is *alive* when the cursor write merely returns an
62//! error, the two writes are also compensated as a unit: a failed cursor write
63//! removes the instances this batch just wrote (and the directories the write
64//! created), so a *returned* failure is completely inert too — the tree is
65//! byte-identical to what it was before the call. A crash gets the first guarantee;
66//! a returned error gets both.
67//!
68//! # The reference label, the path it becomes, and what is not identity
69//!
70//! An instance's name is `"<schedule name> <start date> <sequence>"` — `Weekly sprint
71//! 2026-01-05 000001`. It is a **mutable reference label** an issue's `cycles` value
72//! can hold and the normal cycle machinery resolves; the start date makes it readable
73//! and sortable, and the zero-padded sequence makes it unique for good. Identity is
74//! never the name: it is the generated id `{schedule-id}:{sequence}` (C-003), so
75//! renaming a cycle changes what a human reads and nothing else.
76//!
77//! A label is written to a file whose stem is `slugify(name)`, and `slugify` caps
78//! that stem at 80 characters by cutting the **end** — which is exactly where a
79//! label's discriminating part lives. So the schedule-name component is truncated
80//! *here*, to the documented [`NAME_COMPONENT_BUDGET`] that leaves the canonical date
81//! and the sequence intact, and two instances of one schedule can therefore never
82//! slug to one path. Because a path is still not an identity, every write also checks
83//! the **path** it is about to take against the store's files: a file already
84//! occupied by another identity is a `name_collision` refusal — naming the path and
85//! both identities — with zero writes, never a silent overwrite that loses a
86//! sequence. Likewise a derived *name* that is already taken (by another generated
87//! cycle or by a hand-written one) is refused, through the identity index's own
88//! resolution.
89//!
90//! # No issue is touched, and nothing reads a clock
91//!
92//! Generation moves no issue, rewrites no `## History`, and invents no second
93//! grammar: the audit trail for one generation is the instance record plus the
94//! cursor's `last_generated_key`, the evidence WP02's identity doc names. The
95//! injected [`RuleContext`] is the caller's as-of date — reported on the
96//! [`GenerationReport`] and validated (a named or malformed context timezone is
97//! refused, never normalized) — and is deliberately **not** used to derive a date:
98//! every date comes from the schedule's own anchor through the shared arithmetic
99//! owner, so the same schedule and cursor generate the same instances whenever they
100//! run (C-009).
101//!
102//! # One arithmetic owner
103//!
104//! Cadence steps are [`RecurrenceRule::next_occurrence`] steps, and a created
105//! instance's due date is `start_date + duration` computed by the *same* owner: the
106//! duration is expressed as a rule anchored at the instance's own start, whose first
107//! occurrence strictly after that anchor is the due date. Month-end re-anchoring,
108//! leap days and the interval bound therefore stay in one place — and a duration the
109//! shared owner refuses (a single step above its bound) is a field-aware refusal,
110//! not arithmetic this module invents.
111
112use std::collections::BTreeMap;
113use std::path::{Path, PathBuf};
114
115use time::Date;
116
117use crate::config::Config;
118use crate::cycle_schedule::config::{CycleScheduleConfiguration, CycleScheduleId, ScheduleState};
119use crate::cycle_schedule::identity::{
120    CycleReference, GeneratedCycle, GeneratedCycleId, GeneratedCycleState, GenerationCursor,
121    NextCycleKey, ScheduleIndex, cadence_rule,
122};
123use crate::cycle_store::{CycleRecord, CycleStore};
124use crate::error::{Result, render_diagnostics};
125use crate::recurrence::rule::{RecurrenceRule, RuleContext, RuleKind};
126
127/// The explicit, confirmed intent to generate a schedule's look-ahead (T018).
128///
129/// Every field is supplied and none is inferred: the schedule, the caller's injected
130/// as-of date, how much look-ahead to apply, who asked, and *that they confirmed*. A
131/// surface builds one from a warning it showed the user; nothing constructs a
132/// confirmed one on the user's behalf.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct GenerationRequest {
135    /// The schedule to generate for, by stable id.
136    pub schedule_id: CycleScheduleId,
137    /// The caller's injected as-of date (and timezone policy). Never a clock.
138    pub context: RuleContext,
139    /// How many candidates to apply, starting at [`ScheduleIndex::next_key`].
140    ///
141    /// `0` requests nothing, which is the coherent reading WP03's evaluator already
142    /// fixes: a schedule with no look-ahead has no candidate question to ask.
143    pub look_ahead: u32,
144    /// Who confirmed the action — the caller's audit context. Generation persists no
145    /// History event, so this is reported, not stored.
146    pub actor: String,
147    /// Whether the caller confirmed the action. An unconfirmed request is inert.
148    pub confirmed: bool,
149}
150
151impl GenerationRequest {
152    /// A request that is **not** yet confirmed: naming a schedule is not consent, so
153    /// a caller has to say so explicitly.
154    pub fn new(
155        schedule_id: CycleScheduleId,
156        context: RuleContext,
157        look_ahead: u32,
158        actor: impl Into<String>,
159    ) -> Self {
160        Self {
161            schedule_id,
162            context,
163            look_ahead,
164            actor: actor.into(),
165            confirmed: false,
166        }
167    }
168
169    /// Record the confirmation.
170    #[must_use]
171    pub fn confirm(mut self) -> Self {
172        self.confirmed = true;
173        self
174    }
175}
176
177/// Why a generation was refused, spelled in `snake_case` for payloads and tests.
178///
179/// The vocabulary is deliberately shared with the other cycle-schedule diagnostics
180/// where the fact is the same (`missing_cadence`, `invalid_cadence`,
181/// `unresolvable_date` are WP03's spellings, and `reference_collision` is WP02's), so
182/// one refusal reads the same whichever surface produced it (C-008).
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
184pub enum GenerationDiagnosticKind {
185    /// The request was not confirmed, so nothing was even read.
186    NotConfirmed,
187    /// No schedule with that id is defined.
188    UnknownSchedule,
189    /// The schedule is paused or ended, so generation is not allowed.
190    ScheduleNotActive,
191    /// The schedule names no cadence family, so no candidate can be derived.
192    MissingCadence,
193    /// The cadence resolves to a rule the shared validator refuses.
194    InvalidCadence,
195    /// The schedule's display name, which the reference label is built from, is blank.
196    InvalidScheduleName,
197    /// The duration is missing or a step the shared arithmetic owner refuses.
198    InvalidDuration,
199    /// The schedule's generation position is past the last usable sequence, so there
200    /// is no candidate to create.
201    NoCandidate,
202    /// A date generation needs — a candidate's start, or a due date — cannot be
203    /// derived.
204    UnresolvableDate,
205    /// The injected date context carries a timezone this crate refuses to normalize.
206    InvalidContext,
207    /// The reference label the instance would take is already taken.
208    ReferenceCollision,
209    /// The file the instance would be written to is already occupied by another
210    /// identity — the path, not the name, is what a write takes.
211    NameCollision,
212    /// The instance record could not be written. Nothing else was attempted.
213    SchedulingFailed,
214    /// The instance was written but the cursor could not be persisted; the batch was
215    /// rolled back.
216    CursorNotPersisted,
217}
218
219impl GenerationDiagnosticKind {
220    /// The contract's spelling, used verbatim in payloads and tests.
221    pub const fn as_str(self) -> &'static str {
222        match self {
223            Self::NotConfirmed => "not_confirmed",
224            Self::UnknownSchedule => "unknown_schedule",
225            Self::ScheduleNotActive => "schedule_not_active",
226            Self::MissingCadence => "missing_cadence",
227            Self::InvalidCadence => "invalid_cadence",
228            Self::InvalidScheduleName => "invalid_schedule_name",
229            Self::InvalidDuration => "invalid_duration",
230            Self::NoCandidate => "no_candidate",
231            Self::UnresolvableDate => "unresolvable_date",
232            Self::InvalidContext => "invalid_context",
233            Self::ReferenceCollision => "reference_collision",
234            Self::NameCollision => "name_collision",
235            Self::SchedulingFailed => "scheduling_failure",
236            Self::CursorNotPersisted => "cursor_not_persisted",
237        }
238    }
239}
240
241/// One field-aware reason a generation was refused (T022).
242///
243/// Rendered through the crate's one diagnostic renderer, so the generator, the
244/// evaluator, the CLI and the MCP tools spell the same refusal the same way.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct GenerationDiagnostic {
247    /// What was wrong.
248    pub kind: GenerationDiagnosticKind,
249    /// The configuration or cycle path the caller must fix, e.g.
250    /// `cycle_schedules.cs-weekly.duration.count`.
251    pub field: String,
252    /// One sentence saying what to do about it.
253    pub message: String,
254}
255
256impl GenerationDiagnostic {
257    /// A finding about `field`.
258    fn new(
259        kind: GenerationDiagnosticKind,
260        field: impl Into<String>,
261        message: impl Into<String>,
262    ) -> Self {
263        Self {
264            kind,
265            field: field.into(),
266            message: message.into(),
267        }
268    }
269
270    /// Render through the crate's one diagnostic renderer.
271    #[must_use]
272    pub fn render(&self) -> String {
273        render_diagnostics(
274            "E-tix-cycle-schedule",
275            std::iter::once((self.kind.as_str(), self.message.as_str())),
276        )
277    }
278}
279
280/// The result of one confirmed generation attempt for one requested key
281/// (`data-model.md` §7, T022).
282///
283/// Exactly one of three shapes, and each is a *result* rather than an error: a
284/// surface reports one outcome per candidate, so a refusal is data it shows instead
285/// of an exception it catches.
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub enum GenerationOutcome {
288    /// The instance was created and the cursor advanced past it.
289    Created {
290        /// The schedule it belongs to.
291        schedule_id: CycleScheduleId,
292        /// Its stable key.
293        key: GeneratedCycleId,
294        /// The instance itself: identity, label, effective dates and state.
295        cycle: GeneratedCycle,
296    },
297    /// The key was already taken. Nothing was written and nothing advanced.
298    NoOp {
299        /// The schedule.
300        schedule_id: CycleScheduleId,
301        /// The key that was already taken.
302        key: GeneratedCycleId,
303        /// The lifecycle state of the instance that holds it.
304        state: GeneratedCycleState,
305        /// One sentence saying which instance holds it.
306        message: String,
307    },
308    /// The key (or the whole request) was refused. Nothing was written.
309    Rejected {
310        /// The schedule the request named.
311        schedule_id: CycleScheduleId,
312        /// The key the refusal concerns, when the refusal is about one candidate.
313        key: Option<GeneratedCycleId>,
314        /// Every reason, in the order they were found.
315        diagnostics: Vec<GenerationDiagnostic>,
316    },
317}
318
319impl GenerationOutcome {
320    /// The stable word a surface prints: `created`, `no-op` or `rejected`.
321    #[must_use]
322    pub fn as_str(&self) -> &'static str {
323        match self {
324            Self::Created { .. } => "created",
325            Self::NoOp { .. } => "no-op",
326            Self::Rejected { .. } => "rejected",
327        }
328    }
329
330    /// Whether an instance was created.
331    #[must_use]
332    pub fn is_created(&self) -> bool {
333        matches!(self, Self::Created { .. })
334    }
335
336    /// Whether the key was already taken.
337    #[must_use]
338    pub fn is_noop(&self) -> bool {
339        matches!(self, Self::NoOp { .. })
340    }
341
342    /// Whether the key (or the request) was refused.
343    #[must_use]
344    pub fn is_rejected(&self) -> bool {
345        matches!(self, Self::Rejected { .. })
346    }
347
348    /// The schedule the outcome belongs to.
349    #[must_use]
350    pub fn schedule_id(&self) -> &CycleScheduleId {
351        match self {
352            Self::Created { schedule_id, .. }
353            | Self::NoOp { schedule_id, .. }
354            | Self::Rejected { schedule_id, .. } => schedule_id,
355        }
356    }
357
358    /// The stable key, when the outcome concerns one.
359    #[must_use]
360    pub fn key(&self) -> Option<&GeneratedCycleId> {
361        match self {
362            Self::Created { key, .. } | Self::NoOp { key, .. } => Some(key),
363            Self::Rejected { key, .. } => key.as_ref(),
364        }
365    }
366
367    /// The created instance, present on `created` only.
368    #[must_use]
369    pub fn cycle(&self) -> Option<&GeneratedCycle> {
370        match self {
371            Self::Created { cycle, .. } => Some(cycle),
372            _ => None,
373        }
374    }
375
376    /// The lifecycle state of the instance the outcome is about, when there is one.
377    #[must_use]
378    pub fn state(&self) -> Option<GeneratedCycleState> {
379        match self {
380            Self::Created { cycle, .. } => Some(cycle.state),
381            Self::NoOp { state, .. } => Some(*state),
382            Self::Rejected { .. } => None,
383        }
384    }
385
386    /// Every diagnostic, empty for `created` and `no-op`.
387    #[must_use]
388    pub fn diagnostics(&self) -> &[GenerationDiagnostic] {
389        match self {
390            Self::Rejected { diagnostics, .. } => diagnostics,
391            _ => &[],
392        }
393    }
394
395    /// One line for a surface: what happened, and to what.
396    #[must_use]
397    pub fn render(&self) -> String {
398        match self {
399            Self::Created { key, cycle, .. } => format!(
400                "created `{key}` (`{}`, {} → {})",
401                cycle.name, cycle.start_date, cycle.due_date
402            ),
403            Self::NoOp { key, message, .. } => format!("no-op for `{key}`: {message}"),
404            Self::Rejected {
405                key, diagnostics, ..
406            } => {
407                let first = diagnostics
408                    .first()
409                    .map(GenerationDiagnostic::render)
410                    .unwrap_or_else(|| {
411                        format!(
412                            "`{}` rejected",
413                            key.as_ref().map_or("request", GeneratedCycleId::as_str)
414                        )
415                    });
416                if diagnostics.len() > 1 {
417                    format!("{first} (and {} more)", diagnostics.len() - 1)
418                } else {
419                    first
420                }
421            }
422        }
423    }
424
425    /// A per-key refusal.
426    fn refused(
427        schedule_id: CycleScheduleId,
428        key: Option<GeneratedCycleId>,
429        diagnostic: GenerationDiagnostic,
430    ) -> Self {
431        Self::Rejected {
432            schedule_id,
433            key,
434            diagnostics: vec![diagnostic],
435        }
436    }
437}
438
439/// What one confirmed generation pass did (T022).
440///
441/// Carries the request's audit context (who confirmed it, and the as-of date they
442/// supplied), one [`GenerationOutcome`] per requested key in request order, and the
443/// schedule's cursor after the pass — the next-generation context a surface shows
444/// without re-deriving it.
445#[derive(Debug, Clone, PartialEq)]
446pub struct GenerationReport {
447    actor: String,
448    as_of: Date,
449    schedule_id: CycleScheduleId,
450    outcomes: Vec<GenerationOutcome>,
451    cursor: Option<GenerationCursor>,
452}
453
454impl GenerationReport {
455    /// Who confirmed the pass.
456    #[must_use]
457    pub fn actor(&self) -> &str {
458        &self.actor
459    }
460
461    /// The injected as-of date the caller supplied.
462    #[must_use]
463    pub fn as_of(&self) -> Date {
464        self.as_of
465    }
466
467    /// The schedule the pass was for.
468    #[must_use]
469    pub fn schedule_id(&self) -> &CycleScheduleId {
470        &self.schedule_id
471    }
472
473    /// One outcome per requested key, in request order.
474    #[must_use]
475    pub fn outcomes(&self) -> &[GenerationOutcome] {
476        &self.outcomes
477    }
478
479    /// How many outcomes the pass produced.
480    #[must_use]
481    pub fn len(&self) -> usize {
482        self.outcomes.len()
483    }
484
485    /// Whether the pass requested no key at all (`look_ahead = 0`).
486    #[must_use]
487    pub fn is_empty(&self) -> bool {
488        self.outcomes.is_empty()
489    }
490
491    /// Whether every outcome is a refusal.
492    #[must_use]
493    pub fn is_refused(&self) -> bool {
494        !self.outcomes.is_empty() && self.outcomes.iter().all(GenerationOutcome::is_rejected)
495    }
496
497    /// The schedule's cursor after the pass; absent only when the schedule is unknown.
498    #[must_use]
499    pub fn cursor(&self) -> Option<&GenerationCursor> {
500        self.cursor.as_ref()
501    }
502
503    /// The `created` outcomes.
504    pub fn created(&self) -> impl Iterator<Item = &GenerationOutcome> {
505        self.outcomes.iter().filter(|outcome| outcome.is_created())
506    }
507
508    /// The `no-op` outcomes.
509    pub fn no_ops(&self) -> impl Iterator<Item = &GenerationOutcome> {
510        self.outcomes.iter().filter(|outcome| outcome.is_noop())
511    }
512
513    /// The `rejected` outcomes.
514    pub fn rejected(&self) -> impl Iterator<Item = &GenerationOutcome> {
515        self.outcomes.iter().filter(|outcome| outcome.is_rejected())
516    }
517
518    /// One line for a surface.
519    #[must_use]
520    pub fn render(&self) -> String {
521        if self.outcomes.is_empty() {
522            return format!(
523                "{}: nothing to generate for `{}` (no look-ahead requested)",
524                self.actor, self.schedule_id
525            );
526        }
527        format!(
528            "{}: {} created, {} no-op, {} rejected for `{}`",
529            self.actor,
530            self.created().count(),
531            self.no_ops().count(),
532            self.rejected().count(),
533            self.schedule_id
534        )
535    }
536
537    /// A report whose pass was refused before any key could be derived.
538    fn refused(
539        as_of: Date,
540        actor: &str,
541        schedule_id: CycleScheduleId,
542        diagnostic: GenerationDiagnostic,
543    ) -> Self {
544        Self::refused_many(as_of, actor, schedule_id, vec![diagnostic])
545    }
546
547    /// A report whose pass was refused with every reason it found.
548    fn refused_many(
549        as_of: Date,
550        actor: &str,
551        schedule_id: CycleScheduleId,
552        diagnostics: Vec<GenerationDiagnostic>,
553    ) -> Self {
554        Self {
555            actor: actor.to_string(),
556            as_of,
557            outcomes: vec![GenerationOutcome::Rejected {
558                schedule_id: schedule_id.clone(),
559                key: None,
560                diagnostics,
561            }],
562            schedule_id,
563            cursor: None,
564        }
565    }
566}
567
568/// Generate the look-ahead `request` names, if it is confirmed and still free.
569///
570/// Returns a report rather than an error for every *decision* the operation makes;
571/// only a genuine I/O failure while reading the stores propagates. `config` is the
572/// caller's registry and is mutated only after the cursor has been persisted, so a
573/// failed pass leaves the in-memory configuration exactly as it was.
574///
575/// `root` is the project root: it locates the cycle records (`docs/cycles`) and the
576/// `tix.toml` the cursor is persisted through. `index` is the caller's already-built
577/// read model ([`ScheduleIndex`]) — it supplies the next key, and the durable records
578/// decide whether that key is free.
579#[allow(clippy::too_many_lines)]
580pub fn generate(
581    config: &mut Config,
582    root: &Path,
583    index: &ScheduleIndex,
584    request: &GenerationRequest,
585) -> Result<GenerationReport> {
586    let as_of = request.context.today;
587    let schedule_id = request.schedule_id.clone();
588
589    // T018/C-004: consent first. An unconfirmed request touches nothing at all — not
590    // the record store, not the configuration, not one byte.
591    if !request.confirmed {
592        return Ok(GenerationReport::refused(
593            as_of,
594            &request.actor,
595            schedule_id.clone(),
596            GenerationDiagnostic::new(
597                GenerationDiagnosticKind::NotConfirmed,
598                format!("cycle_schedules.{schedule_id}"),
599                "generation must be confirmed: detection reports a candidate, a person or a \
600                 caller decides, and only then is anything written",
601            ),
602        ));
603    }
604
605    // The injected context is the caller's as-of date. A timezone this crate refuses
606    // to resolve is reported rather than normalized (C-005/C-009) — but note the
607    // context is *not* used to derive a date: generation is anchored at the
608    // schedule's own dates, so the same schedule and cursor produce the same
609    // instances whenever they run.
610    if let Some(timezone) = request.context.timezone.as_ref()
611        && !timezone.is_supported()
612    {
613        return Ok(GenerationReport::refused(
614            as_of,
615            &request.actor,
616            schedule_id.clone(),
617            GenerationDiagnostic::new(
618                GenerationDiagnosticKind::InvalidContext,
619                format!("cycle_schedules.{schedule_id}.timezone"),
620                format!(
621                    "the injected date context carries `{timezone}`, which is not a fixed UTC \
622                     offset; generation will not normalize an ambiguous zone"
623                ),
624            ),
625        ));
626    }
627
628    // T019: every stale or incomplete request is refused before a write, with the
629    // field that has to change.
630    let Some(record) = index.schedule(&schedule_id) else {
631        return Ok(GenerationReport::refused(
632            as_of,
633            &request.actor,
634            schedule_id.clone(),
635            GenerationDiagnostic::new(
636                GenerationDiagnosticKind::UnknownSchedule,
637                format!("cycle_schedules.{schedule_id}"),
638                format!("no cycle schedule `{schedule_id}` is defined"),
639            ),
640        ));
641    };
642    if record.state != ScheduleState::Active {
643        let fix = if record.state.is_terminal() {
644            "an ended schedule is terminal; generation is refused for good"
645        } else {
646            "resume it explicitly before generating"
647        };
648        return Ok(GenerationReport::refused(
649            as_of,
650            &request.actor,
651            schedule_id.clone(),
652            GenerationDiagnostic::new(
653                GenerationDiagnosticKind::ScheduleNotActive,
654                format!("cycle_schedules.{schedule_id}.state"),
655                format!(
656                    "schedule `{schedule_id}` is {}; {fix}",
657                    record.state.as_str()
658                ),
659            ),
660        ));
661    }
662    let Some(rule) = cadence_rule(record) else {
663        return Ok(GenerationReport::refused(
664            as_of,
665            &request.actor,
666            schedule_id.clone(),
667            GenerationDiagnostic::new(
668                GenerationDiagnosticKind::MissingCadence,
669                format!("cycle_schedules.{schedule_id}.cadence.kind"),
670                format!(
671                    "schedule `{schedule_id}` names no cadence family, so no candidate can be \
672                     derived"
673                ),
674            ),
675        ));
676    };
677    if let Err(error) = rule.validate() {
678        let diagnostics = error
679            .diagnostics()
680            .iter()
681            .map(|diagnostic| {
682                GenerationDiagnostic::new(
683                    GenerationDiagnosticKind::InvalidCadence,
684                    format!(
685                        "cycle_schedules.{schedule_id}.{}",
686                        cadence_field(diagnostic.field)
687                    ),
688                    diagnostic.message.clone(),
689                )
690            })
691            .collect();
692        return Ok(GenerationReport::refused_many(
693            as_of,
694            &request.actor,
695            schedule_id.clone(),
696            diagnostics,
697        ));
698    }
699    if record.name.trim().is_empty() {
700        return Ok(GenerationReport::refused(
701            as_of,
702            &request.actor,
703            schedule_id.clone(),
704            GenerationDiagnostic::new(
705                GenerationDiagnosticKind::InvalidScheduleName,
706                format!("cycle_schedules.{schedule_id}.name"),
707                format!(
708                    "schedule `{schedule_id}` has a blank display name, so no reference label can \
709                     be derived for an instance"
710                ),
711            ),
712        ));
713    }
714    // `None` is the read model's own answer for an ended schedule and for one whose
715    // sequence space is used up: there is no candidate, so there is nothing to
716    // generate. The *records* are asked, not the caller's snapshot, so the position is
717    // the one the project actually holds.
718    let store = CycleStore::new(root);
719    let config_path = root.join("tix.toml");
720    let durable = ScheduleIndex::build(config, &store.list()?);
721    // The paths the store already holds, by filename: a write takes a path, and a path
722    // is not an identity (two names can slug to one stem).
723    let mut files = stored_files(&store)?;
724    let Some(durable_next) = durable.next_key(&schedule_id) else {
725        return Ok(GenerationReport::refused(
726            as_of,
727            &request.actor,
728            schedule_id.clone(),
729            GenerationDiagnostic::new(
730                GenerationDiagnosticKind::NoCandidate,
731                format!("cycle_schedules.{schedule_id}.cursor"),
732                format!(
733                    "schedule `{schedule_id}` has no next candidate: its generation position is \
734                     past the last sequence the identity's fixed width can hold"
735                ),
736            ),
737        ));
738    };
739    let cursor_before = durable.cursor(&schedule_id);
740    // The directories the instance write will create, so a rolled-back batch can
741    // remove them again and leave no trace at all.
742    let missing_dirs = missing_ancestors(store.dir());
743
744    // T019: the chain is anchored at the authoritative position. A caller whose
745    // snapshot is *ahead* of the records (a file was removed since) is pulled back to
746    // the records, so generation can never create a sequence beyond a hole; a caller
747    // whose snapshot is *behind* keeps its earlier position, where the keys the records
748    // already hold are no-ops and any key they do not is filled — the batch never
749    // replaces the confirmed window with a later one the surface did not show (C-004).
750    let first = match index.next_key(&schedule_id) {
751        Some(caller_next) if caller_next.sequence < durable_next.sequence => caller_next,
752        _ => durable_next,
753    };
754
755    let keys = candidate_keys(&rule, &first, request.look_ahead);
756    let mut outcomes: Vec<GenerationOutcome> = Vec::new();
757    let mut created: Vec<usize> = Vec::new();
758
759    for key in keys {
760        let id = key.id.clone();
761        // T019: the stable key decides, and the *records* decide it. A key that exists
762        // is a no-op; a key only a stale snapshot remembers — whose file is gone — is
763        // not, which is the same "the snapshot is ahead of the records" case the chain
764        // anchor handles.
765        if let Some(existing) = durable.cycle(&id) {
766            outcomes.push(GenerationOutcome::NoOp {
767                schedule_id: schedule_id.clone(),
768                key: id,
769                state: existing.state,
770                message: format!(
771                    "`{}` already holds it (`{}`); the stable key is idempotent and nothing was \
772                     written",
773                    existing.name,
774                    existing.state.as_str()
775                ),
776            });
777            continue;
778        }
779        // T020: a candidate needs a date. Without one there is nothing to name, and
780        // the batch stops rather than skipping ahead over a gap.
781        let Some(start_date) = key.start_date.clone() else {
782            outcomes.push(GenerationOutcome::refused(
783                schedule_id.clone(),
784                Some(id.clone()),
785                GenerationDiagnostic::new(
786                    GenerationDiagnosticKind::UnresolvableDate,
787                    format!("cycle_schedules.{schedule_id}.cadence"),
788                    format!(
789                        "candidate `{id}` has no resolvable start date: the schedule's cadence \
790                         produced none from the instance it steps from"
791                    ),
792                ),
793            ));
794            break;
795        };
796        // The instance's label must resolve to it and to nothing else (T010).
797        let name = reference_name(&record.name, &start_date, key.sequence);
798        if !matches!(durable.resolve(&name), CycleReference::Unknown { .. }) {
799            outcomes.push(GenerationOutcome::refused(
800                schedule_id.clone(),
801                Some(id.clone()),
802                GenerationDiagnostic::new(
803                    GenerationDiagnosticKind::ReferenceCollision,
804                    format!("cycles.{name}"),
805                    format!(
806                        "the reference label `{name}` an issue would hold already answers to \
807                         another cycle; rename that cycle or the schedule before generating"
808                    ),
809                ),
810            ));
811            break;
812        }
813        // ...and the *path* it takes must not belong to another identity either. Two
814        // different names can slugify to one stem, so the name check above is not
815        // enough: without this, the second key of a batch could overwrite the first
816        // and the record would survive in neither.
817        //
818        // `files` is built from what the record store could *read*, and a write is an
819        // unconditional replace — so the one occupant the map cannot see (a file with
820        // unparseable frontmatter, an interrupted write, a stray non-cycle `.md`) is
821        // exactly the one a write would silently destroy. The path itself is therefore
822        // asked too, and an unreadable occupant is a refusal, not a free slot.
823        let filename = instance_filename(&name);
824        let occupant = files.get(&filename);
825        let path_taken = occupant.is_some() || store.dir().join(&filename).exists();
826        if path_taken {
827            let holds_this_key = occupant
828                .and_then(|record| GeneratedCycle::from_record(record).ok().flatten())
829                .filter(|existing| existing.id == id);
830            if let Some(existing) = holds_this_key {
831                // The path is already *this* key's record: the key is taken, so this is
832                // a no-op like any other existing key — never a rewrite.
833                outcomes.push(GenerationOutcome::NoOp {
834                    schedule_id: schedule_id.clone(),
835                    key: id,
836                    state: existing.state,
837                    message: format!(
838                        "`docs/cycles/{filename}` already holds this key (`{}`); nothing was \
839                         written",
840                        existing.state.as_str()
841                    ),
842                });
843                continue;
844            }
845            let other = match occupant {
846                Some(record) => match GeneratedCycle::from_record(record).ok().flatten() {
847                    Some(existing) => format!("`{}`", existing.id),
848                    None => "a hand-written cycle".to_string(),
849                },
850                None => format!(
851                    "a file `docs/cycles/{filename}` this build cannot read as a cycle record"
852                ),
853            };
854            outcomes.push(GenerationOutcome::refused(
855                schedule_id.clone(),
856                Some(id.clone()),
857                GenerationDiagnostic::new(
858                    GenerationDiagnosticKind::NameCollision,
859                    format!("docs/cycles/{filename}"),
860                    format!(
861                        "`{id}` and {other} would be written to the same file \
862                         `docs/cycles/{filename}`; that can only lose one of them, so \
863                         nothing was written — move the file that is already there"
864                    ),
865                ),
866            ));
867            break;
868        }
869        let due_date = match due_date(&schedule_id, record, &start_date) {
870            Ok(due_date) => due_date,
871            Err(diagnostic) => {
872                outcomes.push(GenerationOutcome::refused(
873                    schedule_id.clone(),
874                    Some(id.clone()),
875                    diagnostic,
876                ));
877                break;
878            }
879        };
880        let cycle = GeneratedCycle {
881            id: id.clone(),
882            schedule_id: schedule_id.clone(),
883            sequence: key.sequence,
884            name,
885            start_date,
886            due_date,
887            state: GeneratedCycleState::Active,
888        };
889        // T021, first half: the instance first, through the one generated-cycle write
890        // path. A failure here means nothing else is attempted — the cursor must not
891        // move for an instance that does not exist.
892        match store.save_record(&cycle.to_record(), None) {
893            Ok(()) => {
894                created.push(outcomes.len());
895                // The path is taken now: a later key of this batch that computed the
896                // same filename would be a collision, never a silent overwrite.
897                files.insert(filename, cycle.to_record());
898                outcomes.push(GenerationOutcome::Created {
899                    schedule_id: schedule_id.clone(),
900                    key: id,
901                    cycle,
902                });
903            }
904            Err(error) => {
905                outcomes.push(GenerationOutcome::refused(
906                    schedule_id.clone(),
907                    Some(id.clone()),
908                    GenerationDiagnostic::new(
909                        GenerationDiagnosticKind::SchedulingFailed,
910                        format!("cycles.{id}"),
911                        format!(
912                            "the instance record for `{id}` could not be written: {error}; the \
913                             cursor was not advanced and nothing else was attempted"
914                        ),
915                    ),
916                ));
917                break;
918            }
919        }
920    }
921
922    // T021, second half: the cursor advances only after successful confirmed work,
923    // and only to the position the instances now prove.
924    let cursor = if created.is_empty() {
925        cursor_before
926    } else {
927        let after = ScheduleIndex::build(config, &store.list()?);
928        let cursor = after.cursor(&schedule_id);
929        let previous = config
930            .cycle_schedules
931            .get(schedule_id.as_str())
932            .and_then(|record| record.cursor.clone());
933        if let Some(record) = config.cycle_schedules.get_mut(schedule_id.as_str()) {
934            record.cursor = Some(cursor.clone());
935        }
936        match config.save(&config_path) {
937            Ok(()) => cursor,
938            Err(error) => {
939                // Alive, so the batch is compensated as a unit: restore the in-memory
940                // cursor, remove the instances this pass wrote, and report every one of
941                // them as refused. (The instance-first order above is what protects a
942                // *crash*; this is what protects a returned error.)
943                if let Some(record) = config.cycle_schedules.get_mut(schedule_id.as_str()) {
944                    record.cursor = previous;
945                }
946                let keys: Vec<String> = created
947                    .iter()
948                    .filter_map(|index| outcomes[*index].key().map(GeneratedCycleId::to_string))
949                    .collect();
950                let rollback = roll_back(&store, &outcomes, &created, &missing_dirs);
951                let message = match rollback {
952                    None => format!(
953                        "the generation cursor for `{schedule_id}` could not be persisted through \
954                         `{}`: {error}; {} was rolled back, so the project is exactly as it was \
955                         and a retry starts from the same key",
956                        config_path.display(),
957                        keys.join(", ")
958                    ),
959                    Some(failures) => format!(
960                        "the generation cursor for `{schedule_id}` could not be persisted through \
961                         `{}`: {error}; rolling back {} also failed ({failures}), so those \
962                         instances remain and a re-run will report them as a no-op while the \
963                         index reports `cursor_drift`",
964                        config_path.display(),
965                        keys.join(", ")
966                    ),
967                };
968                for index in &created {
969                    outcomes[*index] = GenerationOutcome::refused(
970                        schedule_id.clone(),
971                        None,
972                        GenerationDiagnostic::new(
973                            GenerationDiagnosticKind::CursorNotPersisted,
974                            format!("cycle_schedules.{schedule_id}.cursor"),
975                            message.clone(),
976                        ),
977                    );
978                }
979                cursor_before
980            }
981        }
982    };
983
984    Ok(GenerationReport {
985        actor: request.actor.clone(),
986        as_of,
987        schedule_id,
988        outcomes,
989        cursor: Some(cursor),
990    })
991}
992
993/// `slugify`'s cap on a filename stem, and therefore the longest stem a name can
994/// produce.
995const FILENAME_STEM_LIMIT: usize = 80;
996
997/// The length of a canonical `YYYY-MM-DD` date, which the shared arithmetic owner
998/// always produces.
999const CANONICAL_DATE_LEN: usize = 10;
1000
1001/// How much of a schedule's display name a reference label keeps.
1002///
1003/// The label becomes a filename stem through `slugify`, which caps the stem at
1004/// [`FILENAME_STEM_LIMIT`] characters by cutting the **end** — and the end is the
1005/// part that tells two instances apart. Truncating the schedule-name component
1006/// *here* to a budget that leaves the canonical date and the zero-padded sequence
1007/// intact makes that cut impossible: the whole label slugs to at most
1008/// `62 + 1 + 10 + 1 + 6 = 80` characters, whatever the schedule is called. (A char of
1009/// the label contributes at most one byte to the stem, and separator runs collapse,
1010/// so the stem can never be longer than the label.)
1011const NAME_COMPONENT_BUDGET: usize =
1012    FILENAME_STEM_LIMIT - 1 - CANONICAL_DATE_LEN - 1 - GeneratedCycleId::SEQUENCE_WIDTH;
1013
1014/// The reference label an issue's `cycles` value holds for a generated instance.
1015///
1016/// `"<schedule name> <start date> <sequence>"` — `Weekly sprint 2026-01-05 000001`.
1017/// The date is the human part (canonical, so labels sort chronologically); the
1018/// zero-padded sequence is the identity's own discriminator, so even two cycles that
1019/// somehow shared a start date could never share a label. The schedule-name component
1020/// is truncated to [`NAME_COMPONENT_BUDGET`] characters first, which is what keeps
1021/// the label's slug inside `slugify`'s cap (see that constant). It is only ever a
1022/// *label*: the identity is the generated id `{schedule-id}:{sequence}` (C-003).
1023fn reference_name(schedule_name: &str, start_date: &str, sequence: u32) -> String {
1024    let component: String = schedule_name
1025        .trim()
1026        .chars()
1027        .take(NAME_COMPONENT_BUDGET)
1028        .collect();
1029    let component = component.trim_end();
1030    format!(
1031        "{component} {start_date} {sequence:0width$}",
1032        width = GeneratedCycleId::SEQUENCE_WIDTH
1033    )
1034}
1035
1036/// The file `CycleStore::save_record` will write `name` to.
1037///
1038/// Computed exactly the way the store computes it (`slugify(name) + ".md"`), so the
1039/// collision preflight asks about the path the write will actually take rather than
1040/// making a second guess at it.
1041fn instance_filename(name: &str) -> String {
1042    format!("{}.md", crate::slug::slugify(name))
1043}
1044
1045/// Every stored cycle record, keyed by the filename it lives in.
1046///
1047/// A [`ScheduleIndex`] is keyed by identity and by name; a write takes a **path**, and
1048/// neither key is one. Two different names can slugify to one stem, so the path is
1049/// checked directly — that is the only view from which a clobber is visible.
1050fn stored_files(store: &CycleStore) -> Result<BTreeMap<String, CycleRecord>> {
1051    Ok(crate::records::RecordStore::new(store.dir())
1052        .list::<CycleRecord>()?
1053        .into_iter()
1054        .map(|record| (record.filename, record.value))
1055        .collect())
1056}
1057
1058/// The requested candidate keys: the next key, then one shared-rule step each.
1059///
1060/// `look_ahead` counts the candidates, matching WP03's evaluator so a surface
1061/// generates what its own warning listed. A step that the calendar cannot resolve
1062/// leaves the following key without a date; the key is still returned so the caller
1063/// can report the refusal at that key, and the batch stops there.
1064fn candidate_keys(
1065    rule: &RecurrenceRule,
1066    first: &NextCycleKey,
1067    look_ahead: u32,
1068) -> Vec<NextCycleKey> {
1069    let mut keys = Vec::new();
1070    let mut date = first.start_date.clone();
1071    for sequence in (first.sequence..).take(look_ahead as usize) {
1072        if sequence >= GeneratedCycleId::SEQUENCE_LIMIT {
1073            break;
1074        }
1075        keys.push(NextCycleKey {
1076            schedule_id: first.schedule_id.clone(),
1077            sequence,
1078            id: GeneratedCycleId::new(&first.schedule_id, sequence),
1079            start_date: date.clone(),
1080        });
1081        let Some(start) = date else {
1082            break;
1083        };
1084        date = next_start(rule, &start);
1085    }
1086    keys
1087}
1088
1089/// One cadence step after `from`, through the shared arithmetic owner.
1090fn next_start(rule: &RecurrenceRule, from: &str) -> Option<String> {
1091    let date = as_date(from)?;
1092    rule.next_occurrence(&RuleContext::on(date))
1093        .ok()
1094        .map(|key| key.as_str().to_string())
1095}
1096
1097/// A created instance's due date: `start_date + duration`, through the shared owner.
1098///
1099/// The duration is expressed as the rule the shared owner already understands — a
1100/// step of `count` `unit`s anchored at the instance's own start — and its first
1101/// occurrence strictly after that anchor *is* the due date. Month-end re-anchoring
1102/// and leap days are therefore the shared implementation's, not this module's; a step
1103/// the owner refuses (a single step above its bound) is reported at the field the
1104/// user wrote.
1105fn due_date(
1106    schedule_id: &CycleScheduleId,
1107    record: &CycleScheduleConfiguration,
1108    start_date: &str,
1109) -> std::result::Result<String, GenerationDiagnostic> {
1110    let field = format!("cycle_schedules.{schedule_id}.duration");
1111    let Some(unit) = record.duration.unit else {
1112        return Err(GenerationDiagnostic::new(
1113            GenerationDiagnosticKind::InvalidDuration,
1114            format!("{field}.unit"),
1115            "duration.unit: how long one cycle lasts is not configured; give `days`, `weeks` or \
1116             `months`",
1117        ));
1118    };
1119    if record.duration.count == 0 {
1120        return Err(GenerationDiagnostic::new(
1121            GenerationDiagnosticKind::InvalidDuration,
1122            format!("{field}.count"),
1123            "duration.count: a cycle must last at least one unit",
1124        ));
1125    }
1126    let Some(start) = as_date(start_date) else {
1127        return Err(GenerationDiagnostic::new(
1128            GenerationDiagnosticKind::UnresolvableDate,
1129            format!("{field}.count"),
1130            format!(
1131                "the candidate's start date `{start_date}` is not a `YYYY-MM-DD` calendar date, \
1132                 so its duration cannot be added"
1133            ),
1134        ));
1135    };
1136    let rule = RecurrenceRule {
1137        kind: RuleKind::Interval,
1138        interval: Some(record.duration.count),
1139        anchor: Some(start_date.to_string()),
1140        timezone: record.timezone.clone(),
1141        expression: None,
1142        unit: Some(unit),
1143        extensions: BTreeMap::new(),
1144    };
1145    rule.next_occurrence(&RuleContext::on(start))
1146        .map(|due| due.as_str().to_string())
1147        .map_err(|error| {
1148            let first = error.first();
1149            GenerationDiagnostic::new(
1150                GenerationDiagnosticKind::InvalidDuration,
1151                format!("{field}.{}", duration_field(first.field)),
1152                first.message.clone(),
1153            )
1154        })
1155}
1156
1157/// A rule field name, as the schedule configuration spells it.
1158///
1159/// The rule's `anchor` is the schedule's `start_date` and its `timezone` is the
1160/// schedule's own key, which is the same translation WP03's evaluator applies to a
1161/// cadence finding — repeated here because that helper is private to its module.
1162fn cadence_field(field: &str) -> String {
1163    match field {
1164        "anchor" => "start_date".to_string(),
1165        "timezone" => "timezone".to_string(),
1166        other => format!("cadence.{other}"),
1167    }
1168}
1169
1170/// A rule field name, as a duration is spelled in the configuration.
1171///
1172/// The duration's rule carries the instance's start date as its anchor, so a finding
1173/// about that anchor is a finding about the instance's own date; its step is the
1174/// duration's `count`.
1175fn duration_field(field: &str) -> String {
1176    match field {
1177        "interval" => "count".to_string(),
1178        "anchor" => "start_date".to_string(),
1179        "timezone" => "timezone".to_string(),
1180        other => other.to_string(),
1181    }
1182}
1183
1184/// A canonical `YYYY-MM-DD` date as the shared arithmetic owner's value.
1185///
1186/// The rule that a stored date is a real calendar date belongs to
1187/// [`crate::model::validate_date`]; this is the string→value conversion
1188/// [`RuleContext`] needs. The other cycle-schedule modules each keep their own copy
1189/// private for the same reason — the crate enables no `time` parsing feature — so it
1190/// is repeated here rather than re-exported.
1191fn as_date(value: &str) -> Option<Date> {
1192    if !crate::model::validate_date(value) {
1193        return None;
1194    }
1195    let year: i32 = value.get(0..4)?.parse().ok()?;
1196    let month: u8 = value.get(5..7)?.parse().ok()?;
1197    let day: u8 = value.get(8..10)?.parse().ok()?;
1198    Date::from_calendar_date(year, time::Month::try_from(month).ok()?, day).ok()
1199}
1200
1201/// The directories below `dir` that do not exist yet, deepest first.
1202///
1203/// A write creates its directory chain on demand, so a rolled-back batch has to
1204/// remove the chain it created as well as the files in it, or it would leave a
1205/// (now empty) `docs/cycles/` behind that changes what the next `Config::save` sees.
1206fn missing_ancestors(dir: &Path) -> Vec<PathBuf> {
1207    let mut missing = Vec::new();
1208    let mut current = Some(dir.to_path_buf());
1209    while let Some(path) = current {
1210        if path.exists() {
1211            break;
1212        }
1213        missing.push(path.clone());
1214        current = path.parent().map(Path::to_path_buf);
1215    }
1216    missing
1217}
1218
1219/// Remove the instances this batch wrote, and the directories it created.
1220///
1221/// Returns `None` when the compensation was complete, or the names it could not
1222/// remove — a compensation that itself failed must be reported, not hidden.
1223fn roll_back(
1224    store: &CycleStore,
1225    outcomes: &[GenerationOutcome],
1226    created: &[usize],
1227    missing_dirs: &[PathBuf],
1228) -> Option<String> {
1229    let mut failures = Vec::new();
1230    for index in created {
1231        let Some(cycle) = outcomes[*index].cycle() else {
1232            continue;
1233        };
1234        if let Err(error) = store.delete(&cycle.name) {
1235            failures.push(format!("`{}`: {error}", cycle.name));
1236        }
1237    }
1238    for dir in missing_dirs {
1239        let empty = std::fs::read_dir(dir)
1240            .map(|mut entries| entries.next().is_none())
1241            .unwrap_or(false);
1242        if empty {
1243            let _ = std::fs::remove_dir(dir);
1244        }
1245    }
1246    if failures.is_empty() {
1247        None
1248    } else {
1249        Some(failures.join("; "))
1250    }
1251}
1252
1253#[cfg(test)]
1254mod tests {
1255    #[allow(clippy::wildcard_imports)]
1256    use super::*;
1257    use crate::cycle_schedule::config::{Cadence, CycleDuration, CycleScheduleExtensions};
1258    use crate::recurrence::rule::{IntervalUnit, RuleDiagnostic, RuleDiagnosticKind, Timezone};
1259
1260    fn schedule_id() -> CycleScheduleId {
1261        CycleScheduleId::new("cs-weekly").unwrap()
1262    }
1263
1264    fn schedule(duration: CycleDuration) -> CycleScheduleConfiguration {
1265        CycleScheduleConfiguration {
1266            name: "Weekly sprint".to_string(),
1267            cadence: Cadence::new(RuleKind::Weekly),
1268            duration,
1269            start_date: "2026-01-05".to_string(),
1270            timezone: Some(Timezone::parse("UTC")),
1271            look_ahead: 2,
1272            state: ScheduleState::Active,
1273            cursor: None,
1274            extensions: CycleScheduleExtensions::default(),
1275        }
1276    }
1277
1278    #[test]
1279    fn a_request_is_unconfirmed_until_it_says_so() {
1280        let context =
1281            RuleContext::on(Date::from_calendar_date(2026, time::Month::January, 5).unwrap());
1282        let request = GenerationRequest::new(schedule_id(), context.clone(), 2, "alice");
1283        assert!(!request.confirmed, "naming a schedule is not consent");
1284        let confirmed = request.clone().confirm();
1285        assert!(confirmed.confirmed);
1286        assert_eq!(confirmed.actor, "alice");
1287        assert_eq!(confirmed.look_ahead, 2);
1288        assert_eq!(confirmed.context, context);
1289    }
1290
1291    #[test]
1292    fn the_reference_label_keeps_its_discriminating_suffix_through_any_truncation() {
1293        assert_eq!(
1294            reference_name("Weekly sprint", "2026-01-05", 1),
1295            "Weekly sprint 2026-01-05 000001"
1296        );
1297        assert!(
1298            reference_name("Weekly sprint", "2026-01-05", 1)
1299                < reference_name("Weekly sprint", "2026-01-12", 2),
1300            "a canonical date keeps the labels in chronological order"
1301        );
1302
1303        // The longest possible schedule name cannot push the date or the sequence out
1304        // of the stem `slugify` derives. That is the property that keeps two instances
1305        // of one schedule on two paths, whatever the schedule is called.
1306        let long = "x".repeat(500);
1307        let first = reference_name(&long, "2026-01-05", 1);
1308        let second = reference_name(&long, "2026-01-12", 2);
1309        assert_eq!(crate::slug::slugify(&first).len(), FILENAME_STEM_LIMIT);
1310        assert_ne!(crate::slug::slugify(&first), crate::slug::slugify(&second));
1311        assert!(instance_filename(&first).ends_with("-2026-01-05-000001.md"));
1312        assert!(instance_filename(&second).ends_with("-2026-01-12-000002.md"));
1313        assert_eq!(
1314            instance_filename(&first),
1315            instance_filename(&first),
1316            "the path a name becomes is deterministic"
1317        );
1318        for sequence in [1, 999_999] {
1319            let name = reference_name(&long, "2026-01-05", sequence);
1320            assert!(crate::slug::slugify(&name).len() <= FILENAME_STEM_LIMIT);
1321            assert!(instance_filename(&name).contains(&format!("{sequence:06}")));
1322        }
1323    }
1324
1325    #[test]
1326    fn every_outcome_has_one_stable_word() {
1327        let id = GeneratedCycleId::new(&schedule_id(), 1);
1328        let created = GenerationOutcome::Created {
1329            schedule_id: schedule_id(),
1330            key: id.clone(),
1331            cycle: GeneratedCycle {
1332                id: id.clone(),
1333                schedule_id: schedule_id(),
1334                sequence: 1,
1335                name: "Weekly sprint 2026-01-05".to_string(),
1336                start_date: "2026-01-05".to_string(),
1337                due_date: "2026-01-12".to_string(),
1338                state: GeneratedCycleState::Active,
1339            },
1340        };
1341        assert_eq!(created.as_str(), "created");
1342        assert!(created.is_created() && created.cycle().is_some());
1343        assert_eq!(created.key(), Some(&id));
1344        assert!(created.render().contains("2026-01-12"));
1345
1346        let noop = GenerationOutcome::NoOp {
1347            schedule_id: schedule_id(),
1348            key: id.clone(),
1349            state: GeneratedCycleState::Skipped,
1350            message: "already holds it".to_string(),
1351        };
1352        assert_eq!(noop.as_str(), "no-op");
1353        assert!(noop.is_noop());
1354        assert_eq!(noop.state(), Some(GeneratedCycleState::Skipped));
1355
1356        let refused = GenerationOutcome::refused(
1357            schedule_id(),
1358            Some(id.clone()),
1359            GenerationDiagnostic::new(
1360                GenerationDiagnosticKind::ReferenceCollision,
1361                "cycles.x",
1362                "already taken",
1363            ),
1364        );
1365        assert_eq!(refused.as_str(), "rejected");
1366        assert!(refused.is_rejected());
1367        assert!(refused.render().contains("reference_collision"));
1368    }
1369
1370    #[test]
1371    fn the_diagnostic_kinds_are_the_closed_vocabulary() {
1372        let spellings: Vec<&str> = [
1373            GenerationDiagnosticKind::NotConfirmed,
1374            GenerationDiagnosticKind::UnknownSchedule,
1375            GenerationDiagnosticKind::ScheduleNotActive,
1376            GenerationDiagnosticKind::MissingCadence,
1377            GenerationDiagnosticKind::InvalidCadence,
1378            GenerationDiagnosticKind::InvalidScheduleName,
1379            GenerationDiagnosticKind::InvalidDuration,
1380            GenerationDiagnosticKind::NoCandidate,
1381            GenerationDiagnosticKind::UnresolvableDate,
1382            GenerationDiagnosticKind::InvalidContext,
1383            GenerationDiagnosticKind::ReferenceCollision,
1384            GenerationDiagnosticKind::NameCollision,
1385            GenerationDiagnosticKind::SchedulingFailed,
1386            GenerationDiagnosticKind::CursorNotPersisted,
1387        ]
1388        .iter()
1389        .map(|kind| kind.as_str())
1390        .collect();
1391        assert_eq!(
1392            spellings,
1393            vec![
1394                "not_confirmed",
1395                "unknown_schedule",
1396                "schedule_not_active",
1397                "missing_cadence",
1398                "invalid_cadence",
1399                "invalid_schedule_name",
1400                "invalid_duration",
1401                "no_candidate",
1402                "unresolvable_date",
1403                "invalid_context",
1404                "reference_collision",
1405                "name_collision",
1406                "scheduling_failure",
1407                "cursor_not_persisted",
1408            ]
1409        );
1410    }
1411
1412    #[test]
1413    fn a_due_date_is_the_shared_owners_step_including_month_end_and_leap_days() {
1414        // 2026-01-31 + 1 month: the shared owner re-anchors on February's last day.
1415        let monthly = schedule(CycleDuration::new(1, IntervalUnit::Months));
1416        assert_eq!(
1417            due_date(&schedule_id(), &monthly, "2026-01-31").unwrap(),
1418            "2026-02-28"
1419        );
1420        // A leap day anchor keeps the day-of-month when the target month has it.
1421        assert_eq!(
1422            due_date(&schedule_id(), &monthly, "2024-02-29").unwrap(),
1423            "2024-03-29"
1424        );
1425        // Two weeks is exactly fourteen days.
1426        let fortnight = schedule(CycleDuration::new(2, IntervalUnit::Weeks));
1427        assert_eq!(
1428            due_date(&schedule_id(), &fortnight, "2026-01-05").unwrap(),
1429            "2026-01-19"
1430        );
1431    }
1432
1433    #[test]
1434    fn a_duration_the_shared_owner_refuses_is_a_field_aware_refusal_at_the_users_field() {
1435        // A single step above the owner's bound is refused, and the finding is named
1436        // for the configuration field a user wrote — never approximated by this module.
1437        let huge = schedule(CycleDuration::new(1001, IntervalUnit::Weeks));
1438        let error = due_date(&schedule_id(), &huge, "2026-01-05").unwrap_err();
1439        assert_eq!(error.kind, GenerationDiagnosticKind::InvalidDuration);
1440        assert_eq!(error.field, "cycle_schedules.cs-weekly.duration.count");
1441    }
1442
1443    #[test]
1444    fn a_missing_duration_unit_and_a_zero_count_are_named_where_they_are_written() {
1445        let broken = schedule(CycleDuration {
1446            count: 1,
1447            unit: None,
1448            extensions: CycleScheduleExtensions::default(),
1449        });
1450        let error = due_date(&schedule_id(), &broken, "2026-01-05").unwrap_err();
1451        assert_eq!(error.field, "cycle_schedules.cs-weekly.duration.unit");
1452
1453        let zero = schedule(CycleDuration {
1454            count: 0,
1455            unit: Some(IntervalUnit::Weeks),
1456            extensions: CycleScheduleExtensions::default(),
1457        });
1458        let error = due_date(&schedule_id(), &zero, "2026-01-05").unwrap_err();
1459        assert_eq!(error.field, "cycle_schedules.cs-weekly.duration.count");
1460    }
1461
1462    #[test]
1463    fn rule_fields_are_translated_to_the_configuration_fields_they_came_from() {
1464        assert_eq!(cadence_field("anchor"), "start_date");
1465        assert_eq!(cadence_field("interval"), "cadence.interval");
1466        assert_eq!(duration_field("interval"), "count");
1467        assert_eq!(duration_field("unit"), "unit");
1468
1469        // The translation is what turns a shared-owner finding into a path a user can
1470        // find in `tix.toml`.
1471        let diagnostic = RuleDiagnostic {
1472            kind: RuleDiagnosticKind::InvalidAnchor,
1473            field: "anchor",
1474            value: "2026-02-30".to_string(),
1475            message: "anchor: not a real date".to_string(),
1476        };
1477        assert_eq!(
1478            cadence_field(diagnostic.field),
1479            "start_date",
1480            "a cadence anchor is the schedule's start_date"
1481        );
1482    }
1483
1484    #[test]
1485    fn the_candidate_batch_is_the_next_key_then_shared_rule_steps() {
1486        let record = schedule(CycleDuration::new(1, IntervalUnit::Weeks));
1487        let rule = cadence_rule(&record).unwrap();
1488        let first = NextCycleKey {
1489            schedule_id: schedule_id(),
1490            sequence: 1,
1491            id: GeneratedCycleId::new(&schedule_id(), 1),
1492            start_date: Some("2026-01-05".to_string()),
1493        };
1494        let keys = candidate_keys(&rule, &first, 3);
1495        let dates: Vec<Option<String>> = keys.iter().map(|key| key.start_date.clone()).collect();
1496        assert_eq!(
1497            dates,
1498            vec![
1499                Some("2026-01-05".to_string()),
1500                Some("2026-01-12".to_string()),
1501                Some("2026-01-19".to_string()),
1502            ]
1503        );
1504        assert_eq!(keys[2].id.as_str(), "cs-weekly:000003");
1505        assert_eq!(
1506            candidate_keys(&rule, &first, 0),
1507            [] as [crate::cycle_schedule::identity::NextCycleKey; 0]
1508        );
1509    }
1510
1511    #[test]
1512    fn missing_ancestors_stops_at_the_first_directory_that_exists() {
1513        let missing = super::missing_ancestors(Path::new("/definitely/not/a/real/path/cycles"));
1514        assert_eq!(
1515            missing.first().map(PathBuf::as_path),
1516            Some(Path::new("/definitely/not/a/real/path/cycles"))
1517        );
1518        assert!(missing.len() > 1, "every missing ancestor is listed");
1519        assert!(
1520            missing.len() < 8,
1521            "the walk stops at the first existing directory"
1522        );
1523    }
1524}