Skip to main content

tix/recurrence/
generate.rs

1//! Confirmed, idempotent occurrence generation (mission
2//! `recurring-issues-01M3C7G7`, WP04; `data-model.md` §6/§8 and
3//! `contracts/series-generator.md`).
4//!
5//! # Only a confirmed action generates
6//!
7//! [`generate`] is the **only** writer in the recurrence boundary, and it writes
8//! only when the caller says so: [`GenerationRequest::confirmed`] must be set, and
9//! an unconfirmed request is rejected before anything is read or written. That is
10//! C-003's "detection is read-only, every mutation is explicit" as a type, not as a
11//! convention — [`crate::recurrence::detect`] has no way to reach this function,
12//! because it is given no store.
13//!
14//! # Idempotency is a lookup, not a lock
15//!
16//! The occurrence key (`<series-id>@<YYYY-MM-DD>`, WP02) is checked against the
17//! caller's index *before* anything is created. A key that already has an
18//! occurrence is a **no-op**, and so is one the series recorded as skipped: the
19//! second attempt reports what it found and touches nothing (C-006/NFR-002).
20//!
21//! # One transaction, or nothing
22//!
23//! An occurrence is three writes: the issue itself (through the normal
24//! `commands::create::create` core, so no field parsing or date validation is
25//! duplicated), the typed `related` link to the series issue, and the series'
26//! generation cursor. They run inside [`Store::transaction`], so a failure at any
27//! point leaves no partial occurrence, no dangling link and no event — the
28//! property T022 tests by asserting byte-identical files after a failure.
29//!
30//! # What the occurrence records, and what it does not
31//!
32//! The occurrence carries the `series:` table (identity: series id, date, key) and
33//! one `relationship_added` History entry naming the series and the key. That
34//! entry **is** the generation event: the crate's History vocabulary has one kind
35//! per applied decision, and the generation's decision is the link between the
36//! occurrence and the series. The series' `next_cursor` moves to the generated
37//! date in the same transaction — it is bookkeeping for the next detection pass,
38//! not a decision, so it appends nothing and the series' history stays append-only
39//! as it was.
40//!
41//! Occurrence state is independent (C-005): once created, the occurrence is an
42//! ordinary issue. Nothing here ever rewrites it again, and the series holds no
43//! per-occurrence state beyond the cursor and the skipped dates.
44
45use crate::commands::create;
46use crate::config::Config;
47use crate::error::render_diagnostics;
48use crate::model::{HistoryEventKind, SeriesRef};
49use crate::recurrence::rule::DateKey;
50use crate::recurrence::series::{KeyState, OccurrenceKey, SeriesId, SeriesIndex};
51use crate::relationships::RelationKind;
52use crate::relationships::Relationship;
53use crate::storage::Store;
54
55/// The explicit, confirmed intent to generate one occurrence (T017).
56///
57/// Every field is required and none is inferred: the series, the exact key, who
58/// asked, and *that they confirmed*. A surface builds this from a due warning it
59/// showed the user; nothing constructs one on the user's behalf.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct GenerationRequest {
62    /// The series to generate for.
63    pub series_id: SeriesId,
64    /// The occurrence key to generate — the warning's key, not a re-derived one.
65    pub occurrence_key: OccurrenceKey,
66    /// Who asked; lands in the occurrence's History.
67    pub actor: String,
68    /// Whether the caller confirmed the action. An unconfirmed request is inert.
69    pub confirmed: bool,
70}
71
72impl GenerationRequest {
73    /// A request that is **not** yet confirmed: reading the two ids is not
74    /// consent, so a caller has to say so explicitly.
75    pub fn new(
76        series_id: SeriesId,
77        occurrence_key: OccurrenceKey,
78        actor: impl Into<String>,
79    ) -> Self {
80        Self {
81            series_id,
82            occurrence_key,
83            actor: actor.into(),
84            confirmed: false,
85        }
86    }
87
88    /// Record the confirmation.
89    pub fn confirm(mut self) -> Self {
90        self.confirmed = true;
91        self
92    }
93}
94
95/// Why a generation was refused.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
97pub enum GenerationDiagnosticKind {
98    /// The request was not confirmed, so nothing was even read.
99    NotConfirmed,
100    /// `occurrence_key` is not `<series-id>@<YYYY-MM-DD>`.
101    MalformedKey,
102    /// The key names a different series than `series_id`.
103    KeySeriesMismatch,
104    /// No series with that id is defined.
105    UnknownSeries,
106    /// The series is paused or ended, so generation is not allowed.
107    SeriesNotActive,
108    /// The issue that defines the series could not be read.
109    DefinitionUnreadable,
110    /// The create core refused the occurrence (the request's fields).
111    Refused,
112}
113
114impl GenerationDiagnosticKind {
115    /// The stable spelling the CLI and the MCP tools print.
116    pub const fn as_str(self) -> &'static str {
117        match self {
118            Self::NotConfirmed => "not_confirmed",
119            Self::MalformedKey => "malformed_occurrence_key",
120            Self::KeySeriesMismatch => "occurrence_key_series_mismatch",
121            Self::UnknownSeries => "unknown_series",
122            Self::SeriesNotActive => "series_not_active",
123            Self::DefinitionUnreadable => "definition_unreadable",
124            Self::Refused => "refused",
125        }
126    }
127}
128
129/// One actionable reason a generation was refused.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct GenerationDiagnostic {
132    /// What was wrong.
133    pub kind: GenerationDiagnosticKind,
134    /// One sentence saying what to do about it.
135    pub message: String,
136}
137
138impl GenerationDiagnostic {
139    fn new(kind: GenerationDiagnosticKind, message: impl Into<String>) -> Self {
140        Self {
141            kind,
142            message: message.into(),
143        }
144    }
145
146    /// Render through the crate's one diagnostic renderer, so every surface
147    /// spells a refusal the same way.
148    pub fn render(&self) -> String {
149        render_diagnostics(
150            "E-tix-series",
151            std::iter::once((self.kind.as_str(), self.message.as_str())),
152        )
153    }
154}
155
156/// The result of one confirmed generation attempt (`data-model.md` §8, T021).
157///
158/// Exactly one of three shapes, and each is a *result*, not an error: a surface
159/// reports one outcome per candidate, so a refusal is data the caller shows
160/// rather than an exception it has to catch.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum GenerationOutcome {
163    /// The occurrence was created, linked and recorded.
164    Created {
165        /// The series it belongs to.
166        series_id: SeriesId,
167        /// Its occurrence key.
168        key: OccurrenceKey,
169        /// The date it was generated for.
170        date: DateKey,
171        /// The new issue's number.
172        issue_number: u32,
173        /// The new issue's filename.
174        filename: String,
175    },
176    /// The key was already taken: an occurrence exists, or the series skipped it.
177    /// Nothing was written.
178    NoOp {
179        /// The series.
180        series_id: SeriesId,
181        /// The key that was already taken.
182        key: OccurrenceKey,
183        /// Which of the two it was.
184        state: KeyState,
185        /// One sentence saying which.
186        message: String,
187    },
188    /// The action was refused. Nothing was written.
189    Rejected {
190        /// Every reason, in the order they were found.
191        diagnostics: Vec<GenerationDiagnostic>,
192    },
193}
194
195impl GenerationOutcome {
196    /// Whether an occurrence was created.
197    pub fn is_created(&self) -> bool {
198        matches!(self, Self::Created { .. })
199    }
200
201    /// Whether the key was already taken.
202    pub fn is_noop(&self) -> bool {
203        matches!(self, Self::NoOp { .. })
204    }
205
206    /// Whether the action was refused.
207    pub fn is_rejected(&self) -> bool {
208        matches!(self, Self::Rejected { .. })
209    }
210
211    /// The stable word a surface prints: `created`, `no-op` or `rejected`.
212    pub fn as_str(&self) -> &'static str {
213        match self {
214            Self::Created { .. } => "created",
215            Self::NoOp { .. } => "no-op",
216            Self::Rejected { .. } => "rejected",
217        }
218    }
219
220    /// One line for a surface: what happened, and to what.
221    pub fn render(&self) -> String {
222        match self {
223            Self::Created {
224                key,
225                issue_number,
226                filename,
227                ..
228            } => format!("created #{issue_number} for `{key}` ({filename})"),
229            Self::NoOp { key, message, .. } => format!("no-op for `{key}`: {message}"),
230            Self::Rejected { diagnostics } => {
231                let first = diagnostics
232                    .first()
233                    .map(GenerationDiagnostic::render)
234                    .unwrap_or_else(|| "rejected".to_string());
235                if diagnostics.len() > 1 {
236                    format!("{first} (and {} more)", diagnostics.len() - 1)
237                } else {
238                    first
239                }
240            }
241        }
242    }
243}
244
245/// Generate the occurrence `request` names, if it is confirmed and still free.
246///
247/// Returns an outcome rather than an error for every *decision* the operation
248/// makes; only a genuine I/O failure while reading the store propagates. The
249/// three writes (occurrence, link, cursor) are one transaction: either all three
250/// land or none do.
251#[allow(clippy::too_many_lines)]
252pub fn generate(
253    config: &Config,
254    store: &Store,
255    index: &SeriesIndex,
256    request: &GenerationRequest,
257) -> crate::error::Result<GenerationOutcome> {
258    // T017: consent first. An unconfirmed request touches nothing — not even the
259    // store — so there is no state to roll back.
260    if !request.confirmed {
261        return Ok(rejected(GenerationDiagnostic::new(
262            GenerationDiagnosticKind::NotConfirmed,
263            "generation must be confirmed: detection reports, a person or a caller \
264             decides",
265        )));
266    }
267    let (key_series, date) = match OccurrenceKey::parse(request.occurrence_key.as_str()) {
268        Ok(parts) => parts,
269        Err(error) => {
270            return Ok(rejected(GenerationDiagnostic::new(
271                GenerationDiagnosticKind::MalformedKey,
272                error.first().message.clone(),
273            )));
274        }
275    };
276    if key_series != request.series_id {
277        return Ok(rejected(GenerationDiagnostic::new(
278            GenerationDiagnosticKind::KeySeriesMismatch,
279            format!(
280                "key `{}` names series `{key_series}` but the request names `{}`",
281                request.occurrence_key, request.series_id
282            ),
283        )));
284    }
285    // T018: the stable key decides, before anything is created. A generated key
286    // and a skipped key are both no-ops, and they say which they are: the caller
287    // asked twice, and "already created" and "you skipped this one" are different
288    // answers to that.
289    let Some(series) = index.series_by_id(&request.series_id) else {
290        return Ok(rejected(GenerationDiagnostic::new(
291            GenerationDiagnosticKind::UnknownSeries,
292            format!("no series `{}` is defined", request.series_id),
293        )));
294    };
295    match index.key_state(&request.occurrence_key) {
296        KeyState::Generated => {
297            return Ok(GenerationOutcome::NoOp {
298                series_id: series.id.clone(),
299                key: request.occurrence_key.clone(),
300                state: KeyState::Generated,
301                message: format!(
302                    "occurrence `{}` already exists ({})",
303                    request.occurrence_key,
304                    index
305                        .occurrence(&request.occurrence_key)
306                        .map(|record| record.filename.clone())
307                        .unwrap_or_else(|| "in the index".to_string())
308                ),
309            });
310        }
311        KeyState::Skipped => {
312            return Ok(GenerationOutcome::NoOp {
313                series_id: series.id.clone(),
314                key: request.occurrence_key.clone(),
315                state: KeyState::Skipped,
316                message: "the series recorded this date as skipped, and a skipped \
317                          key is never regenerated"
318                    .to_string(),
319            });
320        }
321        KeyState::Free => {}
322    }
323    if !series.allows_generation() {
324        return Ok(rejected(GenerationDiagnostic::new(
325            GenerationDiagnosticKind::SeriesNotActive,
326            format!(
327                "series `{}` is {}; resume or restart it before generating",
328                series.id,
329                series.state.as_str()
330            ),
331        )));
332    }
333
334    // The definition issue is the template: its own create fields are what an
335    // occurrence is made of, which is what "series defaults" means in this model
336    // (the definition *is* the normal issue that carries them).
337    let issues = store.list()?;
338    let Some(definition) = issues
339        .iter()
340        .find(|issue| issue.number == series.issue_number)
341    else {
342        return Ok(rejected(GenerationDiagnostic::new(
343            GenerationDiagnosticKind::DefinitionUnreadable,
344            format!(
345                "series `{}` is defined by issue #{} ({}) but that issue could not be read",
346                series.id, series.issue_number, series.filename
347            ),
348        )));
349    };
350
351    // T019 + T020 + T021 in one transaction. The comma-separated list arguments
352    // are the create core's own grammar, so the occurrence goes through exactly
353    // the parsing and validation a hand-typed `tix create` does.
354    let title = occurrence_title(&definition.title, &date);
355    let labels = definition.labels.join(",");
356    let files = definition.files.join(",");
357    let cycles = definition
358        .cycles
359        .as_ref()
360        .map(|cycles| cycles.join(","))
361        .unwrap_or_default();
362    let milestones = definition
363        .milestones
364        .as_ref()
365        .map(|milestones| milestones.join(","))
366        .unwrap_or_default();
367    let created = store.transaction(|| {
368        let mut occurrence = create::create(
369            config,
370            store,
371            &title,
372            Some(&definition.body),
373            Some(definition.status.clone()),
374            Some(definition.priority.clone()),
375            definition.assignee.as_deref(),
376            Some(labels.as_str()),
377            Some(files.as_str()),
378            definition.estimate.as_deref(),
379            Some(cycles.as_str()),
380            Some(milestones.as_str()),
381            definition.reviewer.as_deref(),
382            definition.start_date.as_deref(),
383            definition.due_date.as_deref(),
384            None,
385            None,
386            definition.type_.as_deref(),
387        )?;
388        // The identity: series id, canonical date, and the stored key.
389        occurrence.series = Some(SeriesRef {
390            id: series.id.as_str().to_string(),
391            name: Some(series.name.clone()),
392            date: Some(date.as_str().to_string()),
393            key: Some(request.occurrence_key.as_str().to_string()),
394            rule: None,
395            state: None,
396            next_cursor: None,
397            skipped: Vec::new(),
398        });
399        // The typed link, and the one event that records the generation.
400        occurrence.relationships.push(Relationship::new(
401            occurrence.number,
402            definition.number,
403            RelationKind::Related,
404            &definition.title,
405        ));
406        occurrence.history.append_now(
407            &request.actor,
408            HistoryEventKind::RelationshipAdded,
409            &format!(
410                "generated occurrence `{}` of series `{}` ({})",
411                request.occurrence_key, series.id, series.name
412            ),
413        );
414        let filename = store.update(&occurrence.filename, &occurrence)?;
415
416        // The cursor is bookkeeping for the next detection pass: it moves to the
417        // date this generation consumed, so the next candidate becomes due. The
418        // series' history is untouched — a cursor is not a decision.
419        let mut definition = definition.clone();
420        if let Some(reference) = definition.series.as_mut() {
421            reference.next_cursor = Some(date.as_str().to_string());
422        }
423        store.update(&definition.filename, &definition)?;
424        Ok((occurrence, filename))
425    });
426
427    match created {
428        Ok((occurrence, filename)) => Ok(GenerationOutcome::Created {
429            series_id: series.id.clone(),
430            key: request.occurrence_key.clone(),
431            date,
432            issue_number: occurrence.number,
433            filename,
434        }),
435        // A refused create (a blank title, a date the calendar rejects, a status
436        // the board does not configure) is a **rejected outcome**, not a crash:
437        // the transaction has already rolled back, so the caller gets a reason and
438        // no half-written occurrence.
439        Err(error) => Ok(rejected(GenerationDiagnostic::new(
440            GenerationDiagnosticKind::Refused,
441            format!("the create core refused the occurrence: {error}"),
442        ))),
443    }
444}
445
446/// The occurrence's title: the series' title plus the date it is for, so two
447/// occurrences of one series are distinguishable in a list without relying on the
448/// key. The key, not the title, stays the identity.
449fn occurrence_title(series_title: &str, date: &DateKey) -> String {
450    format!("{series_title} ({date})")
451}
452
453/// One refusal, as an outcome.
454fn rejected(diagnostic: GenerationDiagnostic) -> GenerationOutcome {
455    GenerationOutcome::Rejected {
456        diagnostics: vec![diagnostic],
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    #[allow(clippy::wildcard_imports)]
463    use super::*;
464
465    /// The date and key every test uses, built through `time` rather than parsed:
466    /// `DateKey` has no public parser (its own module's is private).
467    fn date_and_key() -> (DateKey, OccurrenceKey) {
468        let date = DateKey::from_date(
469            time::Date::from_calendar_date(2026, time::Month::January, 1).expect("a real date"),
470        );
471        let id = SeriesId::parse("s-abc").expect("a valid id");
472        let key = OccurrenceKey::new(&id, &date);
473        (date, key)
474    }
475
476    #[test]
477    fn a_request_is_unconfirmed_until_it_says_so() {
478        let (_, key) = date_and_key();
479        let request =
480            GenerationRequest::new(SeriesId::parse("s-abc").unwrap(), key.clone(), "alice");
481        assert!(!request.confirmed, "reading two ids is not consent");
482        let confirmed = request.clone().confirm();
483        assert!(confirmed.confirmed);
484        assert_eq!(confirmed.actor, "alice");
485        assert_eq!(confirmed.occurrence_key, key);
486    }
487
488    #[test]
489    fn an_occurrence_title_names_the_date_after_the_series_title() {
490        let (date, _) = date_and_key();
491        assert_eq!(
492            occurrence_title("Nightly close", &date),
493            "Nightly close (2026-01-01)"
494        );
495    }
496
497    #[test]
498    fn every_outcome_has_one_stable_word() {
499        let (date, key) = date_and_key();
500        let series_id = SeriesId::parse("s-abc").unwrap();
501
502        let created = GenerationOutcome::Created {
503            series_id: series_id.clone(),
504            key: key.clone(),
505            date: date.clone(),
506            issue_number: 7,
507            filename: "a.md".to_string(),
508        };
509        assert_eq!(created.as_str(), "created");
510        assert!(created.is_created());
511        assert!(created.render().contains("#7"));
512
513        let noop = GenerationOutcome::NoOp {
514            series_id,
515            key,
516            state: KeyState::Generated,
517            message: "already exists".to_string(),
518        };
519        assert_eq!(noop.as_str(), "no-op");
520        assert!(noop.is_noop());
521        assert!(noop.render().contains("already exists"));
522
523        let rejected = GenerationOutcome::Rejected {
524            diagnostics: vec![
525                GenerationDiagnostic::new(
526                    GenerationDiagnosticKind::NotConfirmed,
527                    "generation must be confirmed",
528                ),
529                GenerationDiagnostic::new(GenerationDiagnosticKind::UnknownSeries, "no series"),
530            ],
531        };
532        assert_eq!(rejected.as_str(), "rejected");
533        assert!(rejected.is_rejected());
534        assert!(
535            rejected.render().contains("not_confirmed"),
536            "{}",
537            rejected.render()
538        );
539        assert!(
540            rejected.render().contains("and 1 more"),
541            "a second reason is counted, not hidden: {}",
542            rejected.render()
543        );
544    }
545
546    #[test]
547    fn the_diagnostic_kinds_are_the_closed_set() {
548        let spellings: Vec<&str> = [
549            GenerationDiagnosticKind::NotConfirmed,
550            GenerationDiagnosticKind::MalformedKey,
551            GenerationDiagnosticKind::KeySeriesMismatch,
552            GenerationDiagnosticKind::UnknownSeries,
553            GenerationDiagnosticKind::SeriesNotActive,
554            GenerationDiagnosticKind::DefinitionUnreadable,
555            GenerationDiagnosticKind::Refused,
556        ]
557        .iter()
558        .map(|kind| kind.as_str())
559        .collect();
560        assert_eq!(
561            spellings,
562            vec![
563                "not_confirmed",
564                "malformed_occurrence_key",
565                "occurrence_key_series_mismatch",
566                "unknown_series",
567                "series_not_active",
568                "definition_unreadable",
569                "refused",
570            ]
571        );
572    }
573}