tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
//! Confirmed, idempotent future-cycle generation (WP04, T018–T023).
//!
//! # Only a confirmed action writes, and it writes stable keys
//!
//! [`generate`] is the only writer in the cycle-schedule boundary, and it writes
//! only when the caller says so: [`GenerationRequest::confirmed`] must be set, and
//! an unconfirmed request is refused before anything is read or written. That is
//! C-004's "detection is read-only, every mutation is explicit" expressed as a
//! type rather than a convention — [`crate::cycle_schedule::evaluate`] is handed no
//! store and has no way to reach this function.
//!
//! The unit of work is the **stable key** `(schedule-id, sequence)`, handed out by
//! [`ScheduleIndex::next_key`] and never renumbered or invented. The requested batch
//! is the look-ahead the request carries: the next key, then one step of the
//! schedule's own [`RecurrenceRule`] per further candidate — for a request carrying
//! the schedule's own `look_ahead`, exactly the candidates
//! [`crate::cycle_schedule::evaluate`] reports, so a surface generates what its own
//! warning showed it (C-008). One [`GenerationOutcome`] comes back per requested key:
//! `created`, `no-op`, or `rejected`.
//!
//! # Idempotency is a lookup, and the lookup is durable
//!
//! A key that already exists is a **no-op**: nothing is written, nothing is
//! advanced, and the same bytes stay on disk (C-006/NFR-002). A rejection **ends the
//! batch** instead of skipping ahead, so generation can never create a sequence
//! beyond a gap — the same reason a skipped cycle is *recorded* rather than omitted.
//!
//! Existence is decided against the records that are on disk *now*. The passed
//! [`ScheduleIndex`] supplies the key chain — the caller's view of the next
//! candidate — but an index is a value this very operation invalidates, so a second
//! call under the same snapshot would otherwise re-create a key it had already
//! written. The stored records are the authority, which is what makes a repeated
//! call under the same stable snapshot a no-op rather than a duplicate identity.
//!
//! The chain is anchored the same way. Its **start** is the position the records
//! prove — the authoritative index built inside this call — unless the caller's
//! snapshot is *behind* it, in which case the caller's earlier position is kept: the
//! window that snapshot confirmed has already been generated, so its keys report
//! `no-op` instead of the batch jumping forward past candidates the surface never
//! showed (C-004), and any key of that window the records do not hold is filled. In
//! the other direction, a caller whose snapshot is *ahead* of the records — a file
//! removed since — is pulled back to the records, so generation can never create a
//! sequence beyond a hole. The steps between candidates are always shared-rule steps.
//! Neither direction can name an identity twice or skip a sequence.
//!
//! # The write order is the contract's, and this is the reason
//!
//! A creation is two writes: the **instance record** first (through
//! [`CycleStore::save_record`], the path a generated cycle takes), then the
//! **persisted cursor** (through the existing atomic [`Config::save`] seam — no
//! second seam is added).
//!
//! The order is not arbitrary. A crash between the two writes leaves an instance
//! with a stale cursor, which WP02's index recomputes from the instances and reports
//! as `cursor_drift`, and which a re-run handles as a `no-op` for that key. The
//! reverse order would leave a **skipped sequence** — a cursor that has moved past an
//! instance that does not exist — which nothing can reconstruct, and which C-006
//! forbids above a duplicate identity. Recoverable staleness beats unrecoverable
//! loss.
//!
//! And because this process is *alive* when the cursor write merely returns an
//! error, the two writes are also compensated as a unit: a failed cursor write
//! removes the instances this batch just wrote (and the directories the write
//! created), so a *returned* failure is completely inert too — the tree is
//! byte-identical to what it was before the call. A crash gets the first guarantee;
//! a returned error gets both.
//!
//! # The reference label, the path it becomes, and what is not identity
//!
//! An instance's name is `"<schedule name> <start date> <sequence>"` — `Weekly sprint
//! 2026-01-05 000001`. It is a **mutable reference label** an issue's `cycles` value
//! can hold and the normal cycle machinery resolves; the start date makes it readable
//! and sortable, and the zero-padded sequence makes it unique for good. Identity is
//! never the name: it is the generated id `{schedule-id}:{sequence}` (C-003), so
//! renaming a cycle changes what a human reads and nothing else.
//!
//! A label is written to a file whose stem is `slugify(name)`, and `slugify` caps
//! that stem at 80 characters by cutting the **end** — which is exactly where a
//! label's discriminating part lives. So the schedule-name component is truncated
//! *here*, to the documented [`NAME_COMPONENT_BUDGET`] that leaves the canonical date
//! and the sequence intact, and two instances of one schedule can therefore never
//! slug to one path. Because a path is still not an identity, every write also checks
//! the **path** it is about to take against the store's files: a file already
//! occupied by another identity is a `name_collision` refusal — naming the path and
//! both identities — with zero writes, never a silent overwrite that loses a
//! sequence. Likewise a derived *name* that is already taken (by another generated
//! cycle or by a hand-written one) is refused, through the identity index's own
//! resolution.
//!
//! # No issue is touched, and nothing reads a clock
//!
//! Generation moves no issue, rewrites no `## History`, and invents no second
//! grammar: the audit trail for one generation is the instance record plus the
//! cursor's `last_generated_key`, the evidence WP02's identity doc names. The
//! injected [`RuleContext`] is the caller's as-of date — reported on the
//! [`GenerationReport`] and validated (a named or malformed context timezone is
//! refused, never normalized) — and is deliberately **not** used to derive a date:
//! every date comes from the schedule's own anchor through the shared arithmetic
//! owner, so the same schedule and cursor generate the same instances whenever they
//! run (C-009).
//!
//! # One arithmetic owner
//!
//! Cadence steps are [`RecurrenceRule::next_occurrence`] steps, and a created
//! instance's due date is `start_date + duration` computed by the *same* owner: the
//! duration is expressed as a rule anchored at the instance's own start, whose first
//! occurrence strictly after that anchor is the due date. Month-end re-anchoring,
//! leap days and the interval bound therefore stay in one place — and a duration the
//! shared owner refuses (a single step above its bound) is a field-aware refusal,
//! not arithmetic this module invents.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use time::Date;

use crate::config::Config;
use crate::cycle_schedule::config::{CycleScheduleConfiguration, CycleScheduleId, ScheduleState};
use crate::cycle_schedule::identity::{
    CycleReference, GeneratedCycle, GeneratedCycleId, GeneratedCycleState, GenerationCursor,
    NextCycleKey, ScheduleIndex, cadence_rule,
};
use crate::cycle_store::{CycleRecord, CycleStore};
use crate::error::{Result, render_diagnostics};
use crate::recurrence::rule::{RecurrenceRule, RuleContext, RuleKind};

/// The explicit, confirmed intent to generate a schedule's look-ahead (T018).
///
/// Every field is supplied and none is inferred: the schedule, the caller's injected
/// as-of date, how much look-ahead to apply, who asked, and *that they confirmed*. A
/// surface builds one from a warning it showed the user; nothing constructs a
/// confirmed one on the user's behalf.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GenerationRequest {
    /// The schedule to generate for, by stable id.
    pub schedule_id: CycleScheduleId,
    /// The caller's injected as-of date (and timezone policy). Never a clock.
    pub context: RuleContext,
    /// How many candidates to apply, starting at [`ScheduleIndex::next_key`].
    ///
    /// `0` requests nothing, which is the coherent reading WP03's evaluator already
    /// fixes: a schedule with no look-ahead has no candidate question to ask.
    pub look_ahead: u32,
    /// Who confirmed the action — the caller's audit context. Generation persists no
    /// History event, so this is reported, not stored.
    pub actor: String,
    /// Whether the caller confirmed the action. An unconfirmed request is inert.
    pub confirmed: bool,
}

impl GenerationRequest {
    /// A request that is **not** yet confirmed: naming a schedule is not consent, so
    /// a caller has to say so explicitly.
    pub fn new(
        schedule_id: CycleScheduleId,
        context: RuleContext,
        look_ahead: u32,
        actor: impl Into<String>,
    ) -> Self {
        Self {
            schedule_id,
            context,
            look_ahead,
            actor: actor.into(),
            confirmed: false,
        }
    }

    /// Record the confirmation.
    #[must_use]
    pub fn confirm(mut self) -> Self {
        self.confirmed = true;
        self
    }
}

/// Why a generation was refused, spelled in `snake_case` for payloads and tests.
///
/// The vocabulary is deliberately shared with the other cycle-schedule diagnostics
/// where the fact is the same (`missing_cadence`, `invalid_cadence`,
/// `unresolvable_date` are WP03's spellings, and `reference_collision` is WP02's), so
/// one refusal reads the same whichever surface produced it (C-008).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GenerationDiagnosticKind {
    /// The request was not confirmed, so nothing was even read.
    NotConfirmed,
    /// No schedule with that id is defined.
    UnknownSchedule,
    /// The schedule is paused or ended, so generation is not allowed.
    ScheduleNotActive,
    /// The schedule names no cadence family, so no candidate can be derived.
    MissingCadence,
    /// The cadence resolves to a rule the shared validator refuses.
    InvalidCadence,
    /// The schedule's display name, which the reference label is built from, is blank.
    InvalidScheduleName,
    /// The duration is missing or a step the shared arithmetic owner refuses.
    InvalidDuration,
    /// The schedule's generation position is past the last usable sequence, so there
    /// is no candidate to create.
    NoCandidate,
    /// A date generation needs — a candidate's start, or a due date — cannot be
    /// derived.
    UnresolvableDate,
    /// The injected date context carries a timezone this crate refuses to normalize.
    InvalidContext,
    /// The reference label the instance would take is already taken.
    ReferenceCollision,
    /// The file the instance would be written to is already occupied by another
    /// identity — the path, not the name, is what a write takes.
    NameCollision,
    /// The instance record could not be written. Nothing else was attempted.
    SchedulingFailed,
    /// The instance was written but the cursor could not be persisted; the batch was
    /// rolled back.
    CursorNotPersisted,
}

impl GenerationDiagnosticKind {
    /// The contract's spelling, used verbatim in payloads and tests.
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::NotConfirmed => "not_confirmed",
            Self::UnknownSchedule => "unknown_schedule",
            Self::ScheduleNotActive => "schedule_not_active",
            Self::MissingCadence => "missing_cadence",
            Self::InvalidCadence => "invalid_cadence",
            Self::InvalidScheduleName => "invalid_schedule_name",
            Self::InvalidDuration => "invalid_duration",
            Self::NoCandidate => "no_candidate",
            Self::UnresolvableDate => "unresolvable_date",
            Self::InvalidContext => "invalid_context",
            Self::ReferenceCollision => "reference_collision",
            Self::NameCollision => "name_collision",
            Self::SchedulingFailed => "scheduling_failure",
            Self::CursorNotPersisted => "cursor_not_persisted",
        }
    }
}

/// One field-aware reason a generation was refused (T022).
///
/// Rendered through the crate's one diagnostic renderer, so the generator, the
/// evaluator, the CLI and the MCP tools spell the same refusal the same way.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GenerationDiagnostic {
    /// What was wrong.
    pub kind: GenerationDiagnosticKind,
    /// The configuration or cycle path the caller must fix, e.g.
    /// `cycle_schedules.cs-weekly.duration.count`.
    pub field: String,
    /// One sentence saying what to do about it.
    pub message: String,
}

impl GenerationDiagnostic {
    /// A finding about `field`.
    fn new(
        kind: GenerationDiagnosticKind,
        field: impl Into<String>,
        message: impl Into<String>,
    ) -> Self {
        Self {
            kind,
            field: field.into(),
            message: message.into(),
        }
    }

    /// Render through the crate's one diagnostic renderer.
    #[must_use]
    pub fn render(&self) -> String {
        render_diagnostics(
            "E-tix-cycle-schedule",
            std::iter::once((self.kind.as_str(), self.message.as_str())),
        )
    }
}

/// The result of one confirmed generation attempt for one requested key
/// (`data-model.md` §7, T022).
///
/// Exactly one of three shapes, and each is a *result* rather than an error: a
/// surface reports one outcome per candidate, so a refusal is data it shows instead
/// of an exception it catches.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GenerationOutcome {
    /// The instance was created and the cursor advanced past it.
    Created {
        /// The schedule it belongs to.
        schedule_id: CycleScheduleId,
        /// Its stable key.
        key: GeneratedCycleId,
        /// The instance itself: identity, label, effective dates and state.
        cycle: GeneratedCycle,
    },
    /// The key was already taken. Nothing was written and nothing advanced.
    NoOp {
        /// The schedule.
        schedule_id: CycleScheduleId,
        /// The key that was already taken.
        key: GeneratedCycleId,
        /// The lifecycle state of the instance that holds it.
        state: GeneratedCycleState,
        /// One sentence saying which instance holds it.
        message: String,
    },
    /// The key (or the whole request) was refused. Nothing was written.
    Rejected {
        /// The schedule the request named.
        schedule_id: CycleScheduleId,
        /// The key the refusal concerns, when the refusal is about one candidate.
        key: Option<GeneratedCycleId>,
        /// Every reason, in the order they were found.
        diagnostics: Vec<GenerationDiagnostic>,
    },
}

impl GenerationOutcome {
    /// The stable word a surface prints: `created`, `no-op` or `rejected`.
    #[must_use]
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Created { .. } => "created",
            Self::NoOp { .. } => "no-op",
            Self::Rejected { .. } => "rejected",
        }
    }

    /// Whether an instance was created.
    #[must_use]
    pub fn is_created(&self) -> bool {
        matches!(self, Self::Created { .. })
    }

    /// Whether the key was already taken.
    #[must_use]
    pub fn is_noop(&self) -> bool {
        matches!(self, Self::NoOp { .. })
    }

    /// Whether the key (or the request) was refused.
    #[must_use]
    pub fn is_rejected(&self) -> bool {
        matches!(self, Self::Rejected { .. })
    }

    /// The schedule the outcome belongs to.
    #[must_use]
    pub fn schedule_id(&self) -> &CycleScheduleId {
        match self {
            Self::Created { schedule_id, .. }
            | Self::NoOp { schedule_id, .. }
            | Self::Rejected { schedule_id, .. } => schedule_id,
        }
    }

    /// The stable key, when the outcome concerns one.
    #[must_use]
    pub fn key(&self) -> Option<&GeneratedCycleId> {
        match self {
            Self::Created { key, .. } | Self::NoOp { key, .. } => Some(key),
            Self::Rejected { key, .. } => key.as_ref(),
        }
    }

    /// The created instance, present on `created` only.
    #[must_use]
    pub fn cycle(&self) -> Option<&GeneratedCycle> {
        match self {
            Self::Created { cycle, .. } => Some(cycle),
            _ => None,
        }
    }

    /// The lifecycle state of the instance the outcome is about, when there is one.
    #[must_use]
    pub fn state(&self) -> Option<GeneratedCycleState> {
        match self {
            Self::Created { cycle, .. } => Some(cycle.state),
            Self::NoOp { state, .. } => Some(*state),
            Self::Rejected { .. } => None,
        }
    }

    /// Every diagnostic, empty for `created` and `no-op`.
    #[must_use]
    pub fn diagnostics(&self) -> &[GenerationDiagnostic] {
        match self {
            Self::Rejected { diagnostics, .. } => diagnostics,
            _ => &[],
        }
    }

    /// One line for a surface: what happened, and to what.
    #[must_use]
    pub fn render(&self) -> String {
        match self {
            Self::Created { key, cycle, .. } => format!(
                "created `{key}` (`{}`, {} → {})",
                cycle.name, cycle.start_date, cycle.due_date
            ),
            Self::NoOp { key, message, .. } => format!("no-op for `{key}`: {message}"),
            Self::Rejected {
                key, diagnostics, ..
            } => {
                let first = diagnostics
                    .first()
                    .map(GenerationDiagnostic::render)
                    .unwrap_or_else(|| {
                        format!(
                            "`{}` rejected",
                            key.as_ref().map_or("request", GeneratedCycleId::as_str)
                        )
                    });
                if diagnostics.len() > 1 {
                    format!("{first} (and {} more)", diagnostics.len() - 1)
                } else {
                    first
                }
            }
        }
    }

    /// A per-key refusal.
    fn refused(
        schedule_id: CycleScheduleId,
        key: Option<GeneratedCycleId>,
        diagnostic: GenerationDiagnostic,
    ) -> Self {
        Self::Rejected {
            schedule_id,
            key,
            diagnostics: vec![diagnostic],
        }
    }
}

/// What one confirmed generation pass did (T022).
///
/// Carries the request's audit context (who confirmed it, and the as-of date they
/// supplied), one [`GenerationOutcome`] per requested key in request order, and the
/// schedule's cursor after the pass — the next-generation context a surface shows
/// without re-deriving it.
#[derive(Debug, Clone, PartialEq)]
pub struct GenerationReport {
    actor: String,
    as_of: Date,
    schedule_id: CycleScheduleId,
    outcomes: Vec<GenerationOutcome>,
    cursor: Option<GenerationCursor>,
}

impl GenerationReport {
    /// Who confirmed the pass.
    #[must_use]
    pub fn actor(&self) -> &str {
        &self.actor
    }

    /// The injected as-of date the caller supplied.
    #[must_use]
    pub fn as_of(&self) -> Date {
        self.as_of
    }

    /// The schedule the pass was for.
    #[must_use]
    pub fn schedule_id(&self) -> &CycleScheduleId {
        &self.schedule_id
    }

    /// One outcome per requested key, in request order.
    #[must_use]
    pub fn outcomes(&self) -> &[GenerationOutcome] {
        &self.outcomes
    }

    /// How many outcomes the pass produced.
    #[must_use]
    pub fn len(&self) -> usize {
        self.outcomes.len()
    }

    /// Whether the pass requested no key at all (`look_ahead = 0`).
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.outcomes.is_empty()
    }

    /// Whether every outcome is a refusal.
    #[must_use]
    pub fn is_refused(&self) -> bool {
        !self.outcomes.is_empty() && self.outcomes.iter().all(GenerationOutcome::is_rejected)
    }

    /// The schedule's cursor after the pass; absent only when the schedule is unknown.
    #[must_use]
    pub fn cursor(&self) -> Option<&GenerationCursor> {
        self.cursor.as_ref()
    }

    /// The `created` outcomes.
    pub fn created(&self) -> impl Iterator<Item = &GenerationOutcome> {
        self.outcomes.iter().filter(|outcome| outcome.is_created())
    }

    /// The `no-op` outcomes.
    pub fn no_ops(&self) -> impl Iterator<Item = &GenerationOutcome> {
        self.outcomes.iter().filter(|outcome| outcome.is_noop())
    }

    /// The `rejected` outcomes.
    pub fn rejected(&self) -> impl Iterator<Item = &GenerationOutcome> {
        self.outcomes.iter().filter(|outcome| outcome.is_rejected())
    }

    /// One line for a surface.
    #[must_use]
    pub fn render(&self) -> String {
        if self.outcomes.is_empty() {
            return format!(
                "{}: nothing to generate for `{}` (no look-ahead requested)",
                self.actor, self.schedule_id
            );
        }
        format!(
            "{}: {} created, {} no-op, {} rejected for `{}`",
            self.actor,
            self.created().count(),
            self.no_ops().count(),
            self.rejected().count(),
            self.schedule_id
        )
    }

    /// A report whose pass was refused before any key could be derived.
    fn refused(
        as_of: Date,
        actor: &str,
        schedule_id: CycleScheduleId,
        diagnostic: GenerationDiagnostic,
    ) -> Self {
        Self::refused_many(as_of, actor, schedule_id, vec![diagnostic])
    }

    /// A report whose pass was refused with every reason it found.
    fn refused_many(
        as_of: Date,
        actor: &str,
        schedule_id: CycleScheduleId,
        diagnostics: Vec<GenerationDiagnostic>,
    ) -> Self {
        Self {
            actor: actor.to_string(),
            as_of,
            outcomes: vec![GenerationOutcome::Rejected {
                schedule_id: schedule_id.clone(),
                key: None,
                diagnostics,
            }],
            schedule_id,
            cursor: None,
        }
    }
}

/// Generate the look-ahead `request` names, if it is confirmed and still free.
///
/// Returns a report rather than an error for every *decision* the operation makes;
/// only a genuine I/O failure while reading the stores propagates. `config` is the
/// caller's registry and is mutated only after the cursor has been persisted, so a
/// failed pass leaves the in-memory configuration exactly as it was.
///
/// `root` is the project root: it locates the cycle records (`docs/cycles`) and the
/// `tix.toml` the cursor is persisted through. `index` is the caller's already-built
/// read model ([`ScheduleIndex`]) — it supplies the next key, and the durable records
/// decide whether that key is free.
#[allow(clippy::too_many_lines)]
pub fn generate(
    config: &mut Config,
    root: &Path,
    index: &ScheduleIndex,
    request: &GenerationRequest,
) -> Result<GenerationReport> {
    let as_of = request.context.today;
    let schedule_id = request.schedule_id.clone();

    // T018/C-004: consent first. An unconfirmed request touches nothing at all — not
    // the record store, not the configuration, not one byte.
    if !request.confirmed {
        return Ok(GenerationReport::refused(
            as_of,
            &request.actor,
            schedule_id.clone(),
            GenerationDiagnostic::new(
                GenerationDiagnosticKind::NotConfirmed,
                format!("cycle_schedules.{schedule_id}"),
                "generation must be confirmed: detection reports a candidate, a person or a \
                 caller decides, and only then is anything written",
            ),
        ));
    }

    // The injected context is the caller's as-of date. A timezone this crate refuses
    // to resolve is reported rather than normalized (C-005/C-009) — but note the
    // context is *not* used to derive a date: generation is anchored at the
    // schedule's own dates, so the same schedule and cursor produce the same
    // instances whenever they run.
    if let Some(timezone) = request.context.timezone.as_ref()
        && !timezone.is_supported()
    {
        return Ok(GenerationReport::refused(
            as_of,
            &request.actor,
            schedule_id.clone(),
            GenerationDiagnostic::new(
                GenerationDiagnosticKind::InvalidContext,
                format!("cycle_schedules.{schedule_id}.timezone"),
                format!(
                    "the injected date context carries `{timezone}`, which is not a fixed UTC \
                     offset; generation will not normalize an ambiguous zone"
                ),
            ),
        ));
    }

    // T019: every stale or incomplete request is refused before a write, with the
    // field that has to change.
    let Some(record) = index.schedule(&schedule_id) else {
        return Ok(GenerationReport::refused(
            as_of,
            &request.actor,
            schedule_id.clone(),
            GenerationDiagnostic::new(
                GenerationDiagnosticKind::UnknownSchedule,
                format!("cycle_schedules.{schedule_id}"),
                format!("no cycle schedule `{schedule_id}` is defined"),
            ),
        ));
    };
    if record.state != ScheduleState::Active {
        let fix = if record.state.is_terminal() {
            "an ended schedule is terminal; generation is refused for good"
        } else {
            "resume it explicitly before generating"
        };
        return Ok(GenerationReport::refused(
            as_of,
            &request.actor,
            schedule_id.clone(),
            GenerationDiagnostic::new(
                GenerationDiagnosticKind::ScheduleNotActive,
                format!("cycle_schedules.{schedule_id}.state"),
                format!(
                    "schedule `{schedule_id}` is {}; {fix}",
                    record.state.as_str()
                ),
            ),
        ));
    }
    let Some(rule) = cadence_rule(record) else {
        return Ok(GenerationReport::refused(
            as_of,
            &request.actor,
            schedule_id.clone(),
            GenerationDiagnostic::new(
                GenerationDiagnosticKind::MissingCadence,
                format!("cycle_schedules.{schedule_id}.cadence.kind"),
                format!(
                    "schedule `{schedule_id}` names no cadence family, so no candidate can be \
                     derived"
                ),
            ),
        ));
    };
    if let Err(error) = rule.validate() {
        let diagnostics = error
            .diagnostics()
            .iter()
            .map(|diagnostic| {
                GenerationDiagnostic::new(
                    GenerationDiagnosticKind::InvalidCadence,
                    format!(
                        "cycle_schedules.{schedule_id}.{}",
                        cadence_field(diagnostic.field)
                    ),
                    diagnostic.message.clone(),
                )
            })
            .collect();
        return Ok(GenerationReport::refused_many(
            as_of,
            &request.actor,
            schedule_id.clone(),
            diagnostics,
        ));
    }
    if record.name.trim().is_empty() {
        return Ok(GenerationReport::refused(
            as_of,
            &request.actor,
            schedule_id.clone(),
            GenerationDiagnostic::new(
                GenerationDiagnosticKind::InvalidScheduleName,
                format!("cycle_schedules.{schedule_id}.name"),
                format!(
                    "schedule `{schedule_id}` has a blank display name, so no reference label can \
                     be derived for an instance"
                ),
            ),
        ));
    }
    // `None` is the read model's own answer for an ended schedule and for one whose
    // sequence space is used up: there is no candidate, so there is nothing to
    // generate. The *records* are asked, not the caller's snapshot, so the position is
    // the one the project actually holds.
    let store = CycleStore::new(root);
    let config_path = root.join("tix.toml");
    let durable = ScheduleIndex::build(config, &store.list()?);
    // The paths the store already holds, by filename: a write takes a path, and a path
    // is not an identity (two names can slug to one stem).
    let mut files = stored_files(&store)?;
    let Some(durable_next) = durable.next_key(&schedule_id) else {
        return Ok(GenerationReport::refused(
            as_of,
            &request.actor,
            schedule_id.clone(),
            GenerationDiagnostic::new(
                GenerationDiagnosticKind::NoCandidate,
                format!("cycle_schedules.{schedule_id}.cursor"),
                format!(
                    "schedule `{schedule_id}` has no next candidate: its generation position is \
                     past the last sequence the identity's fixed width can hold"
                ),
            ),
        ));
    };
    let cursor_before = durable.cursor(&schedule_id);
    // The directories the instance write will create, so a rolled-back batch can
    // remove them again and leave no trace at all.
    let missing_dirs = missing_ancestors(store.dir());

    // T019: the chain is anchored at the authoritative position. A caller whose
    // snapshot is *ahead* of the records (a file was removed since) is pulled back to
    // the records, so generation can never create a sequence beyond a hole; a caller
    // whose snapshot is *behind* keeps its earlier position, where the keys the records
    // already hold are no-ops and any key they do not is filled — the batch never
    // replaces the confirmed window with a later one the surface did not show (C-004).
    let first = match index.next_key(&schedule_id) {
        Some(caller_next) if caller_next.sequence < durable_next.sequence => caller_next,
        _ => durable_next,
    };

    let keys = candidate_keys(&rule, &first, request.look_ahead);
    let mut outcomes: Vec<GenerationOutcome> = Vec::new();
    let mut created: Vec<usize> = Vec::new();

    for key in keys {
        let id = key.id.clone();
        // T019: the stable key decides, and the *records* decide it. A key that exists
        // is a no-op; a key only a stale snapshot remembers — whose file is gone — is
        // not, which is the same "the snapshot is ahead of the records" case the chain
        // anchor handles.
        if let Some(existing) = durable.cycle(&id) {
            outcomes.push(GenerationOutcome::NoOp {
                schedule_id: schedule_id.clone(),
                key: id,
                state: existing.state,
                message: format!(
                    "`{}` already holds it (`{}`); the stable key is idempotent and nothing was \
                     written",
                    existing.name,
                    existing.state.as_str()
                ),
            });
            continue;
        }
        // T020: a candidate needs a date. Without one there is nothing to name, and
        // the batch stops rather than skipping ahead over a gap.
        let Some(start_date) = key.start_date.clone() else {
            outcomes.push(GenerationOutcome::refused(
                schedule_id.clone(),
                Some(id.clone()),
                GenerationDiagnostic::new(
                    GenerationDiagnosticKind::UnresolvableDate,
                    format!("cycle_schedules.{schedule_id}.cadence"),
                    format!(
                        "candidate `{id}` has no resolvable start date: the schedule's cadence \
                         produced none from the instance it steps from"
                    ),
                ),
            ));
            break;
        };
        // The instance's label must resolve to it and to nothing else (T010).
        let name = reference_name(&record.name, &start_date, key.sequence);
        if !matches!(durable.resolve(&name), CycleReference::Unknown { .. }) {
            outcomes.push(GenerationOutcome::refused(
                schedule_id.clone(),
                Some(id.clone()),
                GenerationDiagnostic::new(
                    GenerationDiagnosticKind::ReferenceCollision,
                    format!("cycles.{name}"),
                    format!(
                        "the reference label `{name}` an issue would hold already answers to \
                         another cycle; rename that cycle or the schedule before generating"
                    ),
                ),
            ));
            break;
        }
        // ...and the *path* it takes must not belong to another identity either. Two
        // different names can slugify to one stem, so the name check above is not
        // enough: without this, the second key of a batch could overwrite the first
        // and the record would survive in neither.
        //
        // `files` is built from what the record store could *read*, and a write is an
        // unconditional replace — so the one occupant the map cannot see (a file with
        // unparseable frontmatter, an interrupted write, a stray non-cycle `.md`) is
        // exactly the one a write would silently destroy. The path itself is therefore
        // asked too, and an unreadable occupant is a refusal, not a free slot.
        let filename = instance_filename(&name);
        let occupant = files.get(&filename);
        let path_taken = occupant.is_some() || store.dir().join(&filename).exists();
        if path_taken {
            let holds_this_key = occupant
                .and_then(|record| GeneratedCycle::from_record(record).ok().flatten())
                .filter(|existing| existing.id == id);
            if let Some(existing) = holds_this_key {
                // The path is already *this* key's record: the key is taken, so this is
                // a no-op like any other existing key — never a rewrite.
                outcomes.push(GenerationOutcome::NoOp {
                    schedule_id: schedule_id.clone(),
                    key: id,
                    state: existing.state,
                    message: format!(
                        "`docs/cycles/{filename}` already holds this key (`{}`); nothing was \
                         written",
                        existing.state.as_str()
                    ),
                });
                continue;
            }
            let other = match occupant {
                Some(record) => match GeneratedCycle::from_record(record).ok().flatten() {
                    Some(existing) => format!("`{}`", existing.id),
                    None => "a hand-written cycle".to_string(),
                },
                None => format!(
                    "a file `docs/cycles/{filename}` this build cannot read as a cycle record"
                ),
            };
            outcomes.push(GenerationOutcome::refused(
                schedule_id.clone(),
                Some(id.clone()),
                GenerationDiagnostic::new(
                    GenerationDiagnosticKind::NameCollision,
                    format!("docs/cycles/{filename}"),
                    format!(
                        "`{id}` and {other} would be written to the same file \
                         `docs/cycles/{filename}`; that can only lose one of them, so \
                         nothing was written — move the file that is already there"
                    ),
                ),
            ));
            break;
        }
        let due_date = match due_date(&schedule_id, record, &start_date) {
            Ok(due_date) => due_date,
            Err(diagnostic) => {
                outcomes.push(GenerationOutcome::refused(
                    schedule_id.clone(),
                    Some(id.clone()),
                    diagnostic,
                ));
                break;
            }
        };
        let cycle = GeneratedCycle {
            id: id.clone(),
            schedule_id: schedule_id.clone(),
            sequence: key.sequence,
            name,
            start_date,
            due_date,
            state: GeneratedCycleState::Active,
        };
        // T021, first half: the instance first, through the one generated-cycle write
        // path. A failure here means nothing else is attempted — the cursor must not
        // move for an instance that does not exist.
        match store.save_record(&cycle.to_record(), None) {
            Ok(()) => {
                created.push(outcomes.len());
                // The path is taken now: a later key of this batch that computed the
                // same filename would be a collision, never a silent overwrite.
                files.insert(filename, cycle.to_record());
                outcomes.push(GenerationOutcome::Created {
                    schedule_id: schedule_id.clone(),
                    key: id,
                    cycle,
                });
            }
            Err(error) => {
                outcomes.push(GenerationOutcome::refused(
                    schedule_id.clone(),
                    Some(id.clone()),
                    GenerationDiagnostic::new(
                        GenerationDiagnosticKind::SchedulingFailed,
                        format!("cycles.{id}"),
                        format!(
                            "the instance record for `{id}` could not be written: {error}; the \
                             cursor was not advanced and nothing else was attempted"
                        ),
                    ),
                ));
                break;
            }
        }
    }

    // T021, second half: the cursor advances only after successful confirmed work,
    // and only to the position the instances now prove.
    let cursor = if created.is_empty() {
        cursor_before
    } else {
        let after = ScheduleIndex::build(config, &store.list()?);
        let cursor = after.cursor(&schedule_id);
        let previous = config
            .cycle_schedules
            .get(schedule_id.as_str())
            .and_then(|record| record.cursor.clone());
        if let Some(record) = config.cycle_schedules.get_mut(schedule_id.as_str()) {
            record.cursor = Some(cursor.clone());
        }
        match config.save(&config_path) {
            Ok(()) => cursor,
            Err(error) => {
                // Alive, so the batch is compensated as a unit: restore the in-memory
                // cursor, remove the instances this pass wrote, and report every one of
                // them as refused. (The instance-first order above is what protects a
                // *crash*; this is what protects a returned error.)
                if let Some(record) = config.cycle_schedules.get_mut(schedule_id.as_str()) {
                    record.cursor = previous;
                }
                let keys: Vec<String> = created
                    .iter()
                    .filter_map(|index| outcomes[*index].key().map(GeneratedCycleId::to_string))
                    .collect();
                let rollback = roll_back(&store, &outcomes, &created, &missing_dirs);
                let message = match rollback {
                    None => format!(
                        "the generation cursor for `{schedule_id}` could not be persisted through \
                         `{}`: {error}; {} was rolled back, so the project is exactly as it was \
                         and a retry starts from the same key",
                        config_path.display(),
                        keys.join(", ")
                    ),
                    Some(failures) => format!(
                        "the generation cursor for `{schedule_id}` could not be persisted through \
                         `{}`: {error}; rolling back {} also failed ({failures}), so those \
                         instances remain and a re-run will report them as a no-op while the \
                         index reports `cursor_drift`",
                        config_path.display(),
                        keys.join(", ")
                    ),
                };
                for index in &created {
                    outcomes[*index] = GenerationOutcome::refused(
                        schedule_id.clone(),
                        None,
                        GenerationDiagnostic::new(
                            GenerationDiagnosticKind::CursorNotPersisted,
                            format!("cycle_schedules.{schedule_id}.cursor"),
                            message.clone(),
                        ),
                    );
                }
                cursor_before
            }
        }
    };

    Ok(GenerationReport {
        actor: request.actor.clone(),
        as_of,
        schedule_id,
        outcomes,
        cursor: Some(cursor),
    })
}

/// `slugify`'s cap on a filename stem, and therefore the longest stem a name can
/// produce.
const FILENAME_STEM_LIMIT: usize = 80;

/// The length of a canonical `YYYY-MM-DD` date, which the shared arithmetic owner
/// always produces.
const CANONICAL_DATE_LEN: usize = 10;

/// How much of a schedule's display name a reference label keeps.
///
/// The label becomes a filename stem through `slugify`, which caps the stem at
/// [`FILENAME_STEM_LIMIT`] characters by cutting the **end** — and the end is the
/// part that tells two instances apart. Truncating the schedule-name component
/// *here* to a budget that leaves the canonical date and the zero-padded sequence
/// intact makes that cut impossible: the whole label slugs to at most
/// `62 + 1 + 10 + 1 + 6 = 80` characters, whatever the schedule is called. (A char of
/// the label contributes at most one byte to the stem, and separator runs collapse,
/// so the stem can never be longer than the label.)
const NAME_COMPONENT_BUDGET: usize =
    FILENAME_STEM_LIMIT - 1 - CANONICAL_DATE_LEN - 1 - GeneratedCycleId::SEQUENCE_WIDTH;

/// The reference label an issue's `cycles` value holds for a generated instance.
///
/// `"<schedule name> <start date> <sequence>"` — `Weekly sprint 2026-01-05 000001`.
/// The date is the human part (canonical, so labels sort chronologically); the
/// zero-padded sequence is the identity's own discriminator, so even two cycles that
/// somehow shared a start date could never share a label. The schedule-name component
/// is truncated to [`NAME_COMPONENT_BUDGET`] characters first, which is what keeps
/// the label's slug inside `slugify`'s cap (see that constant). It is only ever a
/// *label*: the identity is the generated id `{schedule-id}:{sequence}` (C-003).
fn reference_name(schedule_name: &str, start_date: &str, sequence: u32) -> String {
    let component: String = schedule_name
        .trim()
        .chars()
        .take(NAME_COMPONENT_BUDGET)
        .collect();
    let component = component.trim_end();
    format!(
        "{component} {start_date} {sequence:0width$}",
        width = GeneratedCycleId::SEQUENCE_WIDTH
    )
}

/// The file `CycleStore::save_record` will write `name` to.
///
/// Computed exactly the way the store computes it (`slugify(name) + ".md"`), so the
/// collision preflight asks about the path the write will actually take rather than
/// making a second guess at it.
fn instance_filename(name: &str) -> String {
    format!("{}.md", crate::slug::slugify(name))
}

/// Every stored cycle record, keyed by the filename it lives in.
///
/// A [`ScheduleIndex`] is keyed by identity and by name; a write takes a **path**, and
/// neither key is one. Two different names can slugify to one stem, so the path is
/// checked directly — that is the only view from which a clobber is visible.
fn stored_files(store: &CycleStore) -> Result<BTreeMap<String, CycleRecord>> {
    Ok(crate::records::RecordStore::new(store.dir())
        .list::<CycleRecord>()?
        .into_iter()
        .map(|record| (record.filename, record.value))
        .collect())
}

/// The requested candidate keys: the next key, then one shared-rule step each.
///
/// `look_ahead` counts the candidates, matching WP03's evaluator so a surface
/// generates what its own warning listed. A step that the calendar cannot resolve
/// leaves the following key without a date; the key is still returned so the caller
/// can report the refusal at that key, and the batch stops there.
fn candidate_keys(
    rule: &RecurrenceRule,
    first: &NextCycleKey,
    look_ahead: u32,
) -> Vec<NextCycleKey> {
    let mut keys = Vec::new();
    let mut date = first.start_date.clone();
    for sequence in (first.sequence..).take(look_ahead as usize) {
        if sequence >= GeneratedCycleId::SEQUENCE_LIMIT {
            break;
        }
        keys.push(NextCycleKey {
            schedule_id: first.schedule_id.clone(),
            sequence,
            id: GeneratedCycleId::new(&first.schedule_id, sequence),
            start_date: date.clone(),
        });
        let Some(start) = date else {
            break;
        };
        date = next_start(rule, &start);
    }
    keys
}

/// One cadence step after `from`, through the shared arithmetic owner.
fn next_start(rule: &RecurrenceRule, from: &str) -> Option<String> {
    let date = as_date(from)?;
    rule.next_occurrence(&RuleContext::on(date))
        .ok()
        .map(|key| key.as_str().to_string())
}

/// A created instance's due date: `start_date + duration`, through the shared owner.
///
/// The duration is expressed as the rule the shared owner already understands — a
/// step of `count` `unit`s anchored at the instance's own start — and its first
/// occurrence strictly after that anchor *is* the due date. Month-end re-anchoring
/// and leap days are therefore the shared implementation's, not this module's; a step
/// the owner refuses (a single step above its bound) is reported at the field the
/// user wrote.
fn due_date(
    schedule_id: &CycleScheduleId,
    record: &CycleScheduleConfiguration,
    start_date: &str,
) -> std::result::Result<String, GenerationDiagnostic> {
    let field = format!("cycle_schedules.{schedule_id}.duration");
    let Some(unit) = record.duration.unit else {
        return Err(GenerationDiagnostic::new(
            GenerationDiagnosticKind::InvalidDuration,
            format!("{field}.unit"),
            "duration.unit: how long one cycle lasts is not configured; give `days`, `weeks` or \
             `months`",
        ));
    };
    if record.duration.count == 0 {
        return Err(GenerationDiagnostic::new(
            GenerationDiagnosticKind::InvalidDuration,
            format!("{field}.count"),
            "duration.count: a cycle must last at least one unit",
        ));
    }
    let Some(start) = as_date(start_date) else {
        return Err(GenerationDiagnostic::new(
            GenerationDiagnosticKind::UnresolvableDate,
            format!("{field}.count"),
            format!(
                "the candidate's start date `{start_date}` is not a `YYYY-MM-DD` calendar date, \
                 so its duration cannot be added"
            ),
        ));
    };
    let rule = RecurrenceRule {
        kind: RuleKind::Interval,
        interval: Some(record.duration.count),
        anchor: Some(start_date.to_string()),
        timezone: record.timezone.clone(),
        expression: None,
        unit: Some(unit),
        extensions: BTreeMap::new(),
    };
    rule.next_occurrence(&RuleContext::on(start))
        .map(|due| due.as_str().to_string())
        .map_err(|error| {
            let first = error.first();
            GenerationDiagnostic::new(
                GenerationDiagnosticKind::InvalidDuration,
                format!("{field}.{}", duration_field(first.field)),
                first.message.clone(),
            )
        })
}

/// A rule field name, as the schedule configuration spells it.
///
/// The rule's `anchor` is the schedule's `start_date` and its `timezone` is the
/// schedule's own key, which is the same translation WP03's evaluator applies to a
/// cadence finding — repeated here because that helper is private to its module.
fn cadence_field(field: &str) -> String {
    match field {
        "anchor" => "start_date".to_string(),
        "timezone" => "timezone".to_string(),
        other => format!("cadence.{other}"),
    }
}

/// A rule field name, as a duration is spelled in the configuration.
///
/// The duration's rule carries the instance's start date as its anchor, so a finding
/// about that anchor is a finding about the instance's own date; its step is the
/// duration's `count`.
fn duration_field(field: &str) -> String {
    match field {
        "interval" => "count".to_string(),
        "anchor" => "start_date".to_string(),
        "timezone" => "timezone".to_string(),
        other => other.to_string(),
    }
}

/// A canonical `YYYY-MM-DD` date as the shared arithmetic owner's value.
///
/// The rule that a stored date is a real calendar date belongs to
/// [`crate::model::validate_date`]; this is the string→value conversion
/// [`RuleContext`] needs. The other cycle-schedule modules each keep their own copy
/// private for the same reason — the crate enables no `time` parsing feature — so it
/// is repeated here rather than re-exported.
fn as_date(value: &str) -> Option<Date> {
    if !crate::model::validate_date(value) {
        return None;
    }
    let year: i32 = value.get(0..4)?.parse().ok()?;
    let month: u8 = value.get(5..7)?.parse().ok()?;
    let day: u8 = value.get(8..10)?.parse().ok()?;
    Date::from_calendar_date(year, time::Month::try_from(month).ok()?, day).ok()
}

/// The directories below `dir` that do not exist yet, deepest first.
///
/// A write creates its directory chain on demand, so a rolled-back batch has to
/// remove the chain it created as well as the files in it, or it would leave a
/// (now empty) `docs/cycles/` behind that changes what the next `Config::save` sees.
fn missing_ancestors(dir: &Path) -> Vec<PathBuf> {
    let mut missing = Vec::new();
    let mut current = Some(dir.to_path_buf());
    while let Some(path) = current {
        if path.exists() {
            break;
        }
        missing.push(path.clone());
        current = path.parent().map(Path::to_path_buf);
    }
    missing
}

/// Remove the instances this batch wrote, and the directories it created.
///
/// Returns `None` when the compensation was complete, or the names it could not
/// remove — a compensation that itself failed must be reported, not hidden.
fn roll_back(
    store: &CycleStore,
    outcomes: &[GenerationOutcome],
    created: &[usize],
    missing_dirs: &[PathBuf],
) -> Option<String> {
    let mut failures = Vec::new();
    for index in created {
        let Some(cycle) = outcomes[*index].cycle() else {
            continue;
        };
        if let Err(error) = store.delete(&cycle.name) {
            failures.push(format!("`{}`: {error}", cycle.name));
        }
    }
    for dir in missing_dirs {
        let empty = std::fs::read_dir(dir)
            .map(|mut entries| entries.next().is_none())
            .unwrap_or(false);
        if empty {
            let _ = std::fs::remove_dir(dir);
        }
    }
    if failures.is_empty() {
        None
    } else {
        Some(failures.join("; "))
    }
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;
    use crate::cycle_schedule::config::{Cadence, CycleDuration, CycleScheduleExtensions};
    use crate::recurrence::rule::{IntervalUnit, RuleDiagnostic, RuleDiagnosticKind, Timezone};

    fn schedule_id() -> CycleScheduleId {
        CycleScheduleId::new("cs-weekly").unwrap()
    }

    fn schedule(duration: CycleDuration) -> CycleScheduleConfiguration {
        CycleScheduleConfiguration {
            name: "Weekly sprint".to_string(),
            cadence: Cadence::new(RuleKind::Weekly),
            duration,
            start_date: "2026-01-05".to_string(),
            timezone: Some(Timezone::parse("UTC")),
            look_ahead: 2,
            state: ScheduleState::Active,
            cursor: None,
            extensions: CycleScheduleExtensions::default(),
        }
    }

    #[test]
    fn a_request_is_unconfirmed_until_it_says_so() {
        let context =
            RuleContext::on(Date::from_calendar_date(2026, time::Month::January, 5).unwrap());
        let request = GenerationRequest::new(schedule_id(), context.clone(), 2, "alice");
        assert!(!request.confirmed, "naming a schedule is not consent");
        let confirmed = request.clone().confirm();
        assert!(confirmed.confirmed);
        assert_eq!(confirmed.actor, "alice");
        assert_eq!(confirmed.look_ahead, 2);
        assert_eq!(confirmed.context, context);
    }

    #[test]
    fn the_reference_label_keeps_its_discriminating_suffix_through_any_truncation() {
        assert_eq!(
            reference_name("Weekly sprint", "2026-01-05", 1),
            "Weekly sprint 2026-01-05 000001"
        );
        assert!(
            reference_name("Weekly sprint", "2026-01-05", 1)
                < reference_name("Weekly sprint", "2026-01-12", 2),
            "a canonical date keeps the labels in chronological order"
        );

        // The longest possible schedule name cannot push the date or the sequence out
        // of the stem `slugify` derives. That is the property that keeps two instances
        // of one schedule on two paths, whatever the schedule is called.
        let long = "x".repeat(500);
        let first = reference_name(&long, "2026-01-05", 1);
        let second = reference_name(&long, "2026-01-12", 2);
        assert_eq!(crate::slug::slugify(&first).len(), FILENAME_STEM_LIMIT);
        assert_ne!(crate::slug::slugify(&first), crate::slug::slugify(&second));
        assert!(instance_filename(&first).ends_with("-2026-01-05-000001.md"));
        assert!(instance_filename(&second).ends_with("-2026-01-12-000002.md"));
        assert_eq!(
            instance_filename(&first),
            instance_filename(&first),
            "the path a name becomes is deterministic"
        );
        for sequence in [1, 999_999] {
            let name = reference_name(&long, "2026-01-05", sequence);
            assert!(crate::slug::slugify(&name).len() <= FILENAME_STEM_LIMIT);
            assert!(instance_filename(&name).contains(&format!("{sequence:06}")));
        }
    }

    #[test]
    fn every_outcome_has_one_stable_word() {
        let id = GeneratedCycleId::new(&schedule_id(), 1);
        let created = GenerationOutcome::Created {
            schedule_id: schedule_id(),
            key: id.clone(),
            cycle: GeneratedCycle {
                id: id.clone(),
                schedule_id: schedule_id(),
                sequence: 1,
                name: "Weekly sprint 2026-01-05".to_string(),
                start_date: "2026-01-05".to_string(),
                due_date: "2026-01-12".to_string(),
                state: GeneratedCycleState::Active,
            },
        };
        assert_eq!(created.as_str(), "created");
        assert!(created.is_created() && created.cycle().is_some());
        assert_eq!(created.key(), Some(&id));
        assert!(created.render().contains("2026-01-12"));

        let noop = GenerationOutcome::NoOp {
            schedule_id: schedule_id(),
            key: id.clone(),
            state: GeneratedCycleState::Skipped,
            message: "already holds it".to_string(),
        };
        assert_eq!(noop.as_str(), "no-op");
        assert!(noop.is_noop());
        assert_eq!(noop.state(), Some(GeneratedCycleState::Skipped));

        let refused = GenerationOutcome::refused(
            schedule_id(),
            Some(id.clone()),
            GenerationDiagnostic::new(
                GenerationDiagnosticKind::ReferenceCollision,
                "cycles.x",
                "already taken",
            ),
        );
        assert_eq!(refused.as_str(), "rejected");
        assert!(refused.is_rejected());
        assert!(refused.render().contains("reference_collision"));
    }

    #[test]
    fn the_diagnostic_kinds_are_the_closed_vocabulary() {
        let spellings: Vec<&str> = [
            GenerationDiagnosticKind::NotConfirmed,
            GenerationDiagnosticKind::UnknownSchedule,
            GenerationDiagnosticKind::ScheduleNotActive,
            GenerationDiagnosticKind::MissingCadence,
            GenerationDiagnosticKind::InvalidCadence,
            GenerationDiagnosticKind::InvalidScheduleName,
            GenerationDiagnosticKind::InvalidDuration,
            GenerationDiagnosticKind::NoCandidate,
            GenerationDiagnosticKind::UnresolvableDate,
            GenerationDiagnosticKind::InvalidContext,
            GenerationDiagnosticKind::ReferenceCollision,
            GenerationDiagnosticKind::NameCollision,
            GenerationDiagnosticKind::SchedulingFailed,
            GenerationDiagnosticKind::CursorNotPersisted,
        ]
        .iter()
        .map(|kind| kind.as_str())
        .collect();
        assert_eq!(
            spellings,
            vec![
                "not_confirmed",
                "unknown_schedule",
                "schedule_not_active",
                "missing_cadence",
                "invalid_cadence",
                "invalid_schedule_name",
                "invalid_duration",
                "no_candidate",
                "unresolvable_date",
                "invalid_context",
                "reference_collision",
                "name_collision",
                "scheduling_failure",
                "cursor_not_persisted",
            ]
        );
    }

    #[test]
    fn a_due_date_is_the_shared_owners_step_including_month_end_and_leap_days() {
        // 2026-01-31 + 1 month: the shared owner re-anchors on February's last day.
        let monthly = schedule(CycleDuration::new(1, IntervalUnit::Months));
        assert_eq!(
            due_date(&schedule_id(), &monthly, "2026-01-31").unwrap(),
            "2026-02-28"
        );
        // A leap day anchor keeps the day-of-month when the target month has it.
        assert_eq!(
            due_date(&schedule_id(), &monthly, "2024-02-29").unwrap(),
            "2024-03-29"
        );
        // Two weeks is exactly fourteen days.
        let fortnight = schedule(CycleDuration::new(2, IntervalUnit::Weeks));
        assert_eq!(
            due_date(&schedule_id(), &fortnight, "2026-01-05").unwrap(),
            "2026-01-19"
        );
    }

    #[test]
    fn a_duration_the_shared_owner_refuses_is_a_field_aware_refusal_at_the_users_field() {
        // A single step above the owner's bound is refused, and the finding is named
        // for the configuration field a user wrote — never approximated by this module.
        let huge = schedule(CycleDuration::new(1001, IntervalUnit::Weeks));
        let error = due_date(&schedule_id(), &huge, "2026-01-05").unwrap_err();
        assert_eq!(error.kind, GenerationDiagnosticKind::InvalidDuration);
        assert_eq!(error.field, "cycle_schedules.cs-weekly.duration.count");
    }

    #[test]
    fn a_missing_duration_unit_and_a_zero_count_are_named_where_they_are_written() {
        let broken = schedule(CycleDuration {
            count: 1,
            unit: None,
            extensions: CycleScheduleExtensions::default(),
        });
        let error = due_date(&schedule_id(), &broken, "2026-01-05").unwrap_err();
        assert_eq!(error.field, "cycle_schedules.cs-weekly.duration.unit");

        let zero = schedule(CycleDuration {
            count: 0,
            unit: Some(IntervalUnit::Weeks),
            extensions: CycleScheduleExtensions::default(),
        });
        let error = due_date(&schedule_id(), &zero, "2026-01-05").unwrap_err();
        assert_eq!(error.field, "cycle_schedules.cs-weekly.duration.count");
    }

    #[test]
    fn rule_fields_are_translated_to_the_configuration_fields_they_came_from() {
        assert_eq!(cadence_field("anchor"), "start_date");
        assert_eq!(cadence_field("interval"), "cadence.interval");
        assert_eq!(duration_field("interval"), "count");
        assert_eq!(duration_field("unit"), "unit");

        // The translation is what turns a shared-owner finding into a path a user can
        // find in `tix.toml`.
        let diagnostic = RuleDiagnostic {
            kind: RuleDiagnosticKind::InvalidAnchor,
            field: "anchor",
            value: "2026-02-30".to_string(),
            message: "anchor: not a real date".to_string(),
        };
        assert_eq!(
            cadence_field(diagnostic.field),
            "start_date",
            "a cadence anchor is the schedule's start_date"
        );
    }

    #[test]
    fn the_candidate_batch_is_the_next_key_then_shared_rule_steps() {
        let record = schedule(CycleDuration::new(1, IntervalUnit::Weeks));
        let rule = cadence_rule(&record).unwrap();
        let first = NextCycleKey {
            schedule_id: schedule_id(),
            sequence: 1,
            id: GeneratedCycleId::new(&schedule_id(), 1),
            start_date: Some("2026-01-05".to_string()),
        };
        let keys = candidate_keys(&rule, &first, 3);
        let dates: Vec<Option<String>> = keys.iter().map(|key| key.start_date.clone()).collect();
        assert_eq!(
            dates,
            vec![
                Some("2026-01-05".to_string()),
                Some("2026-01-12".to_string()),
                Some("2026-01-19".to_string()),
            ]
        );
        assert_eq!(keys[2].id.as_str(), "cs-weekly:000003");
        assert_eq!(
            candidate_keys(&rule, &first, 0),
            [] as [crate::cycle_schedule::identity::NextCycleKey; 0]
        );
    }

    #[test]
    fn missing_ancestors_stops_at_the_first_directory_that_exists() {
        let missing = super::missing_ancestors(Path::new("/definitely/not/a/real/path/cycles"));
        assert_eq!(
            missing.first().map(PathBuf::as_path),
            Some(Path::new("/definitely/not/a/real/path/cycles"))
        );
        assert!(missing.len() > 1, "every missing ancestor is listed");
        assert!(
            missing.len() < 8,
            "the walk stops at the first existing directory"
        );
    }
}