1use std::collections::BTreeMap;
113use std::path::{Path, PathBuf};
114
115use time::Date;
116
117use crate::config::Config;
118use crate::cycle_schedule::config::{CycleScheduleConfiguration, CycleScheduleId, ScheduleState};
119use crate::cycle_schedule::identity::{
120 CycleReference, GeneratedCycle, GeneratedCycleId, GeneratedCycleState, GenerationCursor,
121 NextCycleKey, ScheduleIndex, cadence_rule,
122};
123use crate::cycle_store::{CycleRecord, CycleStore};
124use crate::error::{Result, render_diagnostics};
125use crate::recurrence::rule::{RecurrenceRule, RuleContext, RuleKind};
126
127#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct GenerationRequest {
135 pub schedule_id: CycleScheduleId,
137 pub context: RuleContext,
139 pub look_ahead: u32,
144 pub actor: String,
147 pub confirmed: bool,
149}
150
151impl GenerationRequest {
152 pub fn new(
155 schedule_id: CycleScheduleId,
156 context: RuleContext,
157 look_ahead: u32,
158 actor: impl Into<String>,
159 ) -> Self {
160 Self {
161 schedule_id,
162 context,
163 look_ahead,
164 actor: actor.into(),
165 confirmed: false,
166 }
167 }
168
169 #[must_use]
171 pub fn confirm(mut self) -> Self {
172 self.confirmed = true;
173 self
174 }
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
184pub enum GenerationDiagnosticKind {
185 NotConfirmed,
187 UnknownSchedule,
189 ScheduleNotActive,
191 MissingCadence,
193 InvalidCadence,
195 InvalidScheduleName,
197 InvalidDuration,
199 NoCandidate,
202 UnresolvableDate,
205 InvalidContext,
207 ReferenceCollision,
209 NameCollision,
212 SchedulingFailed,
214 CursorNotPersisted,
217}
218
219impl GenerationDiagnosticKind {
220 pub const fn as_str(self) -> &'static str {
222 match self {
223 Self::NotConfirmed => "not_confirmed",
224 Self::UnknownSchedule => "unknown_schedule",
225 Self::ScheduleNotActive => "schedule_not_active",
226 Self::MissingCadence => "missing_cadence",
227 Self::InvalidCadence => "invalid_cadence",
228 Self::InvalidScheduleName => "invalid_schedule_name",
229 Self::InvalidDuration => "invalid_duration",
230 Self::NoCandidate => "no_candidate",
231 Self::UnresolvableDate => "unresolvable_date",
232 Self::InvalidContext => "invalid_context",
233 Self::ReferenceCollision => "reference_collision",
234 Self::NameCollision => "name_collision",
235 Self::SchedulingFailed => "scheduling_failure",
236 Self::CursorNotPersisted => "cursor_not_persisted",
237 }
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct GenerationDiagnostic {
247 pub kind: GenerationDiagnosticKind,
249 pub field: String,
252 pub message: String,
254}
255
256impl GenerationDiagnostic {
257 fn new(
259 kind: GenerationDiagnosticKind,
260 field: impl Into<String>,
261 message: impl Into<String>,
262 ) -> Self {
263 Self {
264 kind,
265 field: field.into(),
266 message: message.into(),
267 }
268 }
269
270 #[must_use]
272 pub fn render(&self) -> String {
273 render_diagnostics(
274 "E-tix-cycle-schedule",
275 std::iter::once((self.kind.as_str(), self.message.as_str())),
276 )
277 }
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
287pub enum GenerationOutcome {
288 Created {
290 schedule_id: CycleScheduleId,
292 key: GeneratedCycleId,
294 cycle: GeneratedCycle,
296 },
297 NoOp {
299 schedule_id: CycleScheduleId,
301 key: GeneratedCycleId,
303 state: GeneratedCycleState,
305 message: String,
307 },
308 Rejected {
310 schedule_id: CycleScheduleId,
312 key: Option<GeneratedCycleId>,
314 diagnostics: Vec<GenerationDiagnostic>,
316 },
317}
318
319impl GenerationOutcome {
320 #[must_use]
322 pub fn as_str(&self) -> &'static str {
323 match self {
324 Self::Created { .. } => "created",
325 Self::NoOp { .. } => "no-op",
326 Self::Rejected { .. } => "rejected",
327 }
328 }
329
330 #[must_use]
332 pub fn is_created(&self) -> bool {
333 matches!(self, Self::Created { .. })
334 }
335
336 #[must_use]
338 pub fn is_noop(&self) -> bool {
339 matches!(self, Self::NoOp { .. })
340 }
341
342 #[must_use]
344 pub fn is_rejected(&self) -> bool {
345 matches!(self, Self::Rejected { .. })
346 }
347
348 #[must_use]
350 pub fn schedule_id(&self) -> &CycleScheduleId {
351 match self {
352 Self::Created { schedule_id, .. }
353 | Self::NoOp { schedule_id, .. }
354 | Self::Rejected { schedule_id, .. } => schedule_id,
355 }
356 }
357
358 #[must_use]
360 pub fn key(&self) -> Option<&GeneratedCycleId> {
361 match self {
362 Self::Created { key, .. } | Self::NoOp { key, .. } => Some(key),
363 Self::Rejected { key, .. } => key.as_ref(),
364 }
365 }
366
367 #[must_use]
369 pub fn cycle(&self) -> Option<&GeneratedCycle> {
370 match self {
371 Self::Created { cycle, .. } => Some(cycle),
372 _ => None,
373 }
374 }
375
376 #[must_use]
378 pub fn state(&self) -> Option<GeneratedCycleState> {
379 match self {
380 Self::Created { cycle, .. } => Some(cycle.state),
381 Self::NoOp { state, .. } => Some(*state),
382 Self::Rejected { .. } => None,
383 }
384 }
385
386 #[must_use]
388 pub fn diagnostics(&self) -> &[GenerationDiagnostic] {
389 match self {
390 Self::Rejected { diagnostics, .. } => diagnostics,
391 _ => &[],
392 }
393 }
394
395 #[must_use]
397 pub fn render(&self) -> String {
398 match self {
399 Self::Created { key, cycle, .. } => format!(
400 "created `{key}` (`{}`, {} → {})",
401 cycle.name, cycle.start_date, cycle.due_date
402 ),
403 Self::NoOp { key, message, .. } => format!("no-op for `{key}`: {message}"),
404 Self::Rejected {
405 key, diagnostics, ..
406 } => {
407 let first = diagnostics
408 .first()
409 .map(GenerationDiagnostic::render)
410 .unwrap_or_else(|| {
411 format!(
412 "`{}` rejected",
413 key.as_ref().map_or("request", GeneratedCycleId::as_str)
414 )
415 });
416 if diagnostics.len() > 1 {
417 format!("{first} (and {} more)", diagnostics.len() - 1)
418 } else {
419 first
420 }
421 }
422 }
423 }
424
425 fn refused(
427 schedule_id: CycleScheduleId,
428 key: Option<GeneratedCycleId>,
429 diagnostic: GenerationDiagnostic,
430 ) -> Self {
431 Self::Rejected {
432 schedule_id,
433 key,
434 diagnostics: vec![diagnostic],
435 }
436 }
437}
438
439#[derive(Debug, Clone, PartialEq)]
446pub struct GenerationReport {
447 actor: String,
448 as_of: Date,
449 schedule_id: CycleScheduleId,
450 outcomes: Vec<GenerationOutcome>,
451 cursor: Option<GenerationCursor>,
452}
453
454impl GenerationReport {
455 #[must_use]
457 pub fn actor(&self) -> &str {
458 &self.actor
459 }
460
461 #[must_use]
463 pub fn as_of(&self) -> Date {
464 self.as_of
465 }
466
467 #[must_use]
469 pub fn schedule_id(&self) -> &CycleScheduleId {
470 &self.schedule_id
471 }
472
473 #[must_use]
475 pub fn outcomes(&self) -> &[GenerationOutcome] {
476 &self.outcomes
477 }
478
479 #[must_use]
481 pub fn len(&self) -> usize {
482 self.outcomes.len()
483 }
484
485 #[must_use]
487 pub fn is_empty(&self) -> bool {
488 self.outcomes.is_empty()
489 }
490
491 #[must_use]
493 pub fn is_refused(&self) -> bool {
494 !self.outcomes.is_empty() && self.outcomes.iter().all(GenerationOutcome::is_rejected)
495 }
496
497 #[must_use]
499 pub fn cursor(&self) -> Option<&GenerationCursor> {
500 self.cursor.as_ref()
501 }
502
503 pub fn created(&self) -> impl Iterator<Item = &GenerationOutcome> {
505 self.outcomes.iter().filter(|outcome| outcome.is_created())
506 }
507
508 pub fn no_ops(&self) -> impl Iterator<Item = &GenerationOutcome> {
510 self.outcomes.iter().filter(|outcome| outcome.is_noop())
511 }
512
513 pub fn rejected(&self) -> impl Iterator<Item = &GenerationOutcome> {
515 self.outcomes.iter().filter(|outcome| outcome.is_rejected())
516 }
517
518 #[must_use]
520 pub fn render(&self) -> String {
521 if self.outcomes.is_empty() {
522 return format!(
523 "{}: nothing to generate for `{}` (no look-ahead requested)",
524 self.actor, self.schedule_id
525 );
526 }
527 format!(
528 "{}: {} created, {} no-op, {} rejected for `{}`",
529 self.actor,
530 self.created().count(),
531 self.no_ops().count(),
532 self.rejected().count(),
533 self.schedule_id
534 )
535 }
536
537 fn refused(
539 as_of: Date,
540 actor: &str,
541 schedule_id: CycleScheduleId,
542 diagnostic: GenerationDiagnostic,
543 ) -> Self {
544 Self::refused_many(as_of, actor, schedule_id, vec![diagnostic])
545 }
546
547 fn refused_many(
549 as_of: Date,
550 actor: &str,
551 schedule_id: CycleScheduleId,
552 diagnostics: Vec<GenerationDiagnostic>,
553 ) -> Self {
554 Self {
555 actor: actor.to_string(),
556 as_of,
557 outcomes: vec![GenerationOutcome::Rejected {
558 schedule_id: schedule_id.clone(),
559 key: None,
560 diagnostics,
561 }],
562 schedule_id,
563 cursor: None,
564 }
565 }
566}
567
568#[allow(clippy::too_many_lines)]
580pub fn generate(
581 config: &mut Config,
582 root: &Path,
583 index: &ScheduleIndex,
584 request: &GenerationRequest,
585) -> Result<GenerationReport> {
586 let as_of = request.context.today;
587 let schedule_id = request.schedule_id.clone();
588
589 if !request.confirmed {
592 return Ok(GenerationReport::refused(
593 as_of,
594 &request.actor,
595 schedule_id.clone(),
596 GenerationDiagnostic::new(
597 GenerationDiagnosticKind::NotConfirmed,
598 format!("cycle_schedules.{schedule_id}"),
599 "generation must be confirmed: detection reports a candidate, a person or a \
600 caller decides, and only then is anything written",
601 ),
602 ));
603 }
604
605 if let Some(timezone) = request.context.timezone.as_ref()
611 && !timezone.is_supported()
612 {
613 return Ok(GenerationReport::refused(
614 as_of,
615 &request.actor,
616 schedule_id.clone(),
617 GenerationDiagnostic::new(
618 GenerationDiagnosticKind::InvalidContext,
619 format!("cycle_schedules.{schedule_id}.timezone"),
620 format!(
621 "the injected date context carries `{timezone}`, which is not a fixed UTC \
622 offset; generation will not normalize an ambiguous zone"
623 ),
624 ),
625 ));
626 }
627
628 let Some(record) = index.schedule(&schedule_id) else {
631 return Ok(GenerationReport::refused(
632 as_of,
633 &request.actor,
634 schedule_id.clone(),
635 GenerationDiagnostic::new(
636 GenerationDiagnosticKind::UnknownSchedule,
637 format!("cycle_schedules.{schedule_id}"),
638 format!("no cycle schedule `{schedule_id}` is defined"),
639 ),
640 ));
641 };
642 if record.state != ScheduleState::Active {
643 let fix = if record.state.is_terminal() {
644 "an ended schedule is terminal; generation is refused for good"
645 } else {
646 "resume it explicitly before generating"
647 };
648 return Ok(GenerationReport::refused(
649 as_of,
650 &request.actor,
651 schedule_id.clone(),
652 GenerationDiagnostic::new(
653 GenerationDiagnosticKind::ScheduleNotActive,
654 format!("cycle_schedules.{schedule_id}.state"),
655 format!(
656 "schedule `{schedule_id}` is {}; {fix}",
657 record.state.as_str()
658 ),
659 ),
660 ));
661 }
662 let Some(rule) = cadence_rule(record) else {
663 return Ok(GenerationReport::refused(
664 as_of,
665 &request.actor,
666 schedule_id.clone(),
667 GenerationDiagnostic::new(
668 GenerationDiagnosticKind::MissingCadence,
669 format!("cycle_schedules.{schedule_id}.cadence.kind"),
670 format!(
671 "schedule `{schedule_id}` names no cadence family, so no candidate can be \
672 derived"
673 ),
674 ),
675 ));
676 };
677 if let Err(error) = rule.validate() {
678 let diagnostics = error
679 .diagnostics()
680 .iter()
681 .map(|diagnostic| {
682 GenerationDiagnostic::new(
683 GenerationDiagnosticKind::InvalidCadence,
684 format!(
685 "cycle_schedules.{schedule_id}.{}",
686 cadence_field(diagnostic.field)
687 ),
688 diagnostic.message.clone(),
689 )
690 })
691 .collect();
692 return Ok(GenerationReport::refused_many(
693 as_of,
694 &request.actor,
695 schedule_id.clone(),
696 diagnostics,
697 ));
698 }
699 if record.name.trim().is_empty() {
700 return Ok(GenerationReport::refused(
701 as_of,
702 &request.actor,
703 schedule_id.clone(),
704 GenerationDiagnostic::new(
705 GenerationDiagnosticKind::InvalidScheduleName,
706 format!("cycle_schedules.{schedule_id}.name"),
707 format!(
708 "schedule `{schedule_id}` has a blank display name, so no reference label can \
709 be derived for an instance"
710 ),
711 ),
712 ));
713 }
714 let store = CycleStore::new(root);
719 let config_path = root.join("tix.toml");
720 let durable = ScheduleIndex::build(config, &store.list()?);
721 let mut files = stored_files(&store)?;
724 let Some(durable_next) = durable.next_key(&schedule_id) else {
725 return Ok(GenerationReport::refused(
726 as_of,
727 &request.actor,
728 schedule_id.clone(),
729 GenerationDiagnostic::new(
730 GenerationDiagnosticKind::NoCandidate,
731 format!("cycle_schedules.{schedule_id}.cursor"),
732 format!(
733 "schedule `{schedule_id}` has no next candidate: its generation position is \
734 past the last sequence the identity's fixed width can hold"
735 ),
736 ),
737 ));
738 };
739 let cursor_before = durable.cursor(&schedule_id);
740 let missing_dirs = missing_ancestors(store.dir());
743
744 let first = match index.next_key(&schedule_id) {
751 Some(caller_next) if caller_next.sequence < durable_next.sequence => caller_next,
752 _ => durable_next,
753 };
754
755 let keys = candidate_keys(&rule, &first, request.look_ahead);
756 let mut outcomes: Vec<GenerationOutcome> = Vec::new();
757 let mut created: Vec<usize> = Vec::new();
758
759 for key in keys {
760 let id = key.id.clone();
761 if let Some(existing) = durable.cycle(&id) {
766 outcomes.push(GenerationOutcome::NoOp {
767 schedule_id: schedule_id.clone(),
768 key: id,
769 state: existing.state,
770 message: format!(
771 "`{}` already holds it (`{}`); the stable key is idempotent and nothing was \
772 written",
773 existing.name,
774 existing.state.as_str()
775 ),
776 });
777 continue;
778 }
779 let Some(start_date) = key.start_date.clone() else {
782 outcomes.push(GenerationOutcome::refused(
783 schedule_id.clone(),
784 Some(id.clone()),
785 GenerationDiagnostic::new(
786 GenerationDiagnosticKind::UnresolvableDate,
787 format!("cycle_schedules.{schedule_id}.cadence"),
788 format!(
789 "candidate `{id}` has no resolvable start date: the schedule's cadence \
790 produced none from the instance it steps from"
791 ),
792 ),
793 ));
794 break;
795 };
796 let name = reference_name(&record.name, &start_date, key.sequence);
798 if !matches!(durable.resolve(&name), CycleReference::Unknown { .. }) {
799 outcomes.push(GenerationOutcome::refused(
800 schedule_id.clone(),
801 Some(id.clone()),
802 GenerationDiagnostic::new(
803 GenerationDiagnosticKind::ReferenceCollision,
804 format!("cycles.{name}"),
805 format!(
806 "the reference label `{name}` an issue would hold already answers to \
807 another cycle; rename that cycle or the schedule before generating"
808 ),
809 ),
810 ));
811 break;
812 }
813 let filename = instance_filename(&name);
824 let occupant = files.get(&filename);
825 let path_taken = occupant.is_some() || store.dir().join(&filename).exists();
826 if path_taken {
827 let holds_this_key = occupant
828 .and_then(|record| GeneratedCycle::from_record(record).ok().flatten())
829 .filter(|existing| existing.id == id);
830 if let Some(existing) = holds_this_key {
831 outcomes.push(GenerationOutcome::NoOp {
834 schedule_id: schedule_id.clone(),
835 key: id,
836 state: existing.state,
837 message: format!(
838 "`docs/cycles/{filename}` already holds this key (`{}`); nothing was \
839 written",
840 existing.state.as_str()
841 ),
842 });
843 continue;
844 }
845 let other = match occupant {
846 Some(record) => match GeneratedCycle::from_record(record).ok().flatten() {
847 Some(existing) => format!("`{}`", existing.id),
848 None => "a hand-written cycle".to_string(),
849 },
850 None => format!(
851 "a file `docs/cycles/{filename}` this build cannot read as a cycle record"
852 ),
853 };
854 outcomes.push(GenerationOutcome::refused(
855 schedule_id.clone(),
856 Some(id.clone()),
857 GenerationDiagnostic::new(
858 GenerationDiagnosticKind::NameCollision,
859 format!("docs/cycles/{filename}"),
860 format!(
861 "`{id}` and {other} would be written to the same file \
862 `docs/cycles/{filename}`; that can only lose one of them, so \
863 nothing was written — move the file that is already there"
864 ),
865 ),
866 ));
867 break;
868 }
869 let due_date = match due_date(&schedule_id, record, &start_date) {
870 Ok(due_date) => due_date,
871 Err(diagnostic) => {
872 outcomes.push(GenerationOutcome::refused(
873 schedule_id.clone(),
874 Some(id.clone()),
875 diagnostic,
876 ));
877 break;
878 }
879 };
880 let cycle = GeneratedCycle {
881 id: id.clone(),
882 schedule_id: schedule_id.clone(),
883 sequence: key.sequence,
884 name,
885 start_date,
886 due_date,
887 state: GeneratedCycleState::Active,
888 };
889 match store.save_record(&cycle.to_record(), None) {
893 Ok(()) => {
894 created.push(outcomes.len());
895 files.insert(filename, cycle.to_record());
898 outcomes.push(GenerationOutcome::Created {
899 schedule_id: schedule_id.clone(),
900 key: id,
901 cycle,
902 });
903 }
904 Err(error) => {
905 outcomes.push(GenerationOutcome::refused(
906 schedule_id.clone(),
907 Some(id.clone()),
908 GenerationDiagnostic::new(
909 GenerationDiagnosticKind::SchedulingFailed,
910 format!("cycles.{id}"),
911 format!(
912 "the instance record for `{id}` could not be written: {error}; the \
913 cursor was not advanced and nothing else was attempted"
914 ),
915 ),
916 ));
917 break;
918 }
919 }
920 }
921
922 let cursor = if created.is_empty() {
925 cursor_before
926 } else {
927 let after = ScheduleIndex::build(config, &store.list()?);
928 let cursor = after.cursor(&schedule_id);
929 let previous = config
930 .cycle_schedules
931 .get(schedule_id.as_str())
932 .and_then(|record| record.cursor.clone());
933 if let Some(record) = config.cycle_schedules.get_mut(schedule_id.as_str()) {
934 record.cursor = Some(cursor.clone());
935 }
936 match config.save(&config_path) {
937 Ok(()) => cursor,
938 Err(error) => {
939 if let Some(record) = config.cycle_schedules.get_mut(schedule_id.as_str()) {
944 record.cursor = previous;
945 }
946 let keys: Vec<String> = created
947 .iter()
948 .filter_map(|index| outcomes[*index].key().map(GeneratedCycleId::to_string))
949 .collect();
950 let rollback = roll_back(&store, &outcomes, &created, &missing_dirs);
951 let message = match rollback {
952 None => format!(
953 "the generation cursor for `{schedule_id}` could not be persisted through \
954 `{}`: {error}; {} was rolled back, so the project is exactly as it was \
955 and a retry starts from the same key",
956 config_path.display(),
957 keys.join(", ")
958 ),
959 Some(failures) => format!(
960 "the generation cursor for `{schedule_id}` could not be persisted through \
961 `{}`: {error}; rolling back {} also failed ({failures}), so those \
962 instances remain and a re-run will report them as a no-op while the \
963 index reports `cursor_drift`",
964 config_path.display(),
965 keys.join(", ")
966 ),
967 };
968 for index in &created {
969 outcomes[*index] = GenerationOutcome::refused(
970 schedule_id.clone(),
971 None,
972 GenerationDiagnostic::new(
973 GenerationDiagnosticKind::CursorNotPersisted,
974 format!("cycle_schedules.{schedule_id}.cursor"),
975 message.clone(),
976 ),
977 );
978 }
979 cursor_before
980 }
981 }
982 };
983
984 Ok(GenerationReport {
985 actor: request.actor.clone(),
986 as_of,
987 schedule_id,
988 outcomes,
989 cursor: Some(cursor),
990 })
991}
992
993const FILENAME_STEM_LIMIT: usize = 80;
996
997const CANONICAL_DATE_LEN: usize = 10;
1000
1001const NAME_COMPONENT_BUDGET: usize =
1012 FILENAME_STEM_LIMIT - 1 - CANONICAL_DATE_LEN - 1 - GeneratedCycleId::SEQUENCE_WIDTH;
1013
1014fn reference_name(schedule_name: &str, start_date: &str, sequence: u32) -> String {
1024 let component: String = schedule_name
1025 .trim()
1026 .chars()
1027 .take(NAME_COMPONENT_BUDGET)
1028 .collect();
1029 let component = component.trim_end();
1030 format!(
1031 "{component} {start_date} {sequence:0width$}",
1032 width = GeneratedCycleId::SEQUENCE_WIDTH
1033 )
1034}
1035
1036fn instance_filename(name: &str) -> String {
1042 format!("{}.md", crate::slug::slugify(name))
1043}
1044
1045fn stored_files(store: &CycleStore) -> Result<BTreeMap<String, CycleRecord>> {
1051 Ok(crate::records::RecordStore::new(store.dir())
1052 .list::<CycleRecord>()?
1053 .into_iter()
1054 .map(|record| (record.filename, record.value))
1055 .collect())
1056}
1057
1058fn candidate_keys(
1065 rule: &RecurrenceRule,
1066 first: &NextCycleKey,
1067 look_ahead: u32,
1068) -> Vec<NextCycleKey> {
1069 let mut keys = Vec::new();
1070 let mut date = first.start_date.clone();
1071 for sequence in (first.sequence..).take(look_ahead as usize) {
1072 if sequence >= GeneratedCycleId::SEQUENCE_LIMIT {
1073 break;
1074 }
1075 keys.push(NextCycleKey {
1076 schedule_id: first.schedule_id.clone(),
1077 sequence,
1078 id: GeneratedCycleId::new(&first.schedule_id, sequence),
1079 start_date: date.clone(),
1080 });
1081 let Some(start) = date else {
1082 break;
1083 };
1084 date = next_start(rule, &start);
1085 }
1086 keys
1087}
1088
1089fn next_start(rule: &RecurrenceRule, from: &str) -> Option<String> {
1091 let date = as_date(from)?;
1092 rule.next_occurrence(&RuleContext::on(date))
1093 .ok()
1094 .map(|key| key.as_str().to_string())
1095}
1096
1097fn due_date(
1106 schedule_id: &CycleScheduleId,
1107 record: &CycleScheduleConfiguration,
1108 start_date: &str,
1109) -> std::result::Result<String, GenerationDiagnostic> {
1110 let field = format!("cycle_schedules.{schedule_id}.duration");
1111 let Some(unit) = record.duration.unit else {
1112 return Err(GenerationDiagnostic::new(
1113 GenerationDiagnosticKind::InvalidDuration,
1114 format!("{field}.unit"),
1115 "duration.unit: how long one cycle lasts is not configured; give `days`, `weeks` or \
1116 `months`",
1117 ));
1118 };
1119 if record.duration.count == 0 {
1120 return Err(GenerationDiagnostic::new(
1121 GenerationDiagnosticKind::InvalidDuration,
1122 format!("{field}.count"),
1123 "duration.count: a cycle must last at least one unit",
1124 ));
1125 }
1126 let Some(start) = as_date(start_date) else {
1127 return Err(GenerationDiagnostic::new(
1128 GenerationDiagnosticKind::UnresolvableDate,
1129 format!("{field}.count"),
1130 format!(
1131 "the candidate's start date `{start_date}` is not a `YYYY-MM-DD` calendar date, \
1132 so its duration cannot be added"
1133 ),
1134 ));
1135 };
1136 let rule = RecurrenceRule {
1137 kind: RuleKind::Interval,
1138 interval: Some(record.duration.count),
1139 anchor: Some(start_date.to_string()),
1140 timezone: record.timezone.clone(),
1141 expression: None,
1142 unit: Some(unit),
1143 extensions: BTreeMap::new(),
1144 };
1145 rule.next_occurrence(&RuleContext::on(start))
1146 .map(|due| due.as_str().to_string())
1147 .map_err(|error| {
1148 let first = error.first();
1149 GenerationDiagnostic::new(
1150 GenerationDiagnosticKind::InvalidDuration,
1151 format!("{field}.{}", duration_field(first.field)),
1152 first.message.clone(),
1153 )
1154 })
1155}
1156
1157fn cadence_field(field: &str) -> String {
1163 match field {
1164 "anchor" => "start_date".to_string(),
1165 "timezone" => "timezone".to_string(),
1166 other => format!("cadence.{other}"),
1167 }
1168}
1169
1170fn duration_field(field: &str) -> String {
1176 match field {
1177 "interval" => "count".to_string(),
1178 "anchor" => "start_date".to_string(),
1179 "timezone" => "timezone".to_string(),
1180 other => other.to_string(),
1181 }
1182}
1183
1184fn as_date(value: &str) -> Option<Date> {
1192 if !crate::model::validate_date(value) {
1193 return None;
1194 }
1195 let year: i32 = value.get(0..4)?.parse().ok()?;
1196 let month: u8 = value.get(5..7)?.parse().ok()?;
1197 let day: u8 = value.get(8..10)?.parse().ok()?;
1198 Date::from_calendar_date(year, time::Month::try_from(month).ok()?, day).ok()
1199}
1200
1201fn missing_ancestors(dir: &Path) -> Vec<PathBuf> {
1207 let mut missing = Vec::new();
1208 let mut current = Some(dir.to_path_buf());
1209 while let Some(path) = current {
1210 if path.exists() {
1211 break;
1212 }
1213 missing.push(path.clone());
1214 current = path.parent().map(Path::to_path_buf);
1215 }
1216 missing
1217}
1218
1219fn roll_back(
1224 store: &CycleStore,
1225 outcomes: &[GenerationOutcome],
1226 created: &[usize],
1227 missing_dirs: &[PathBuf],
1228) -> Option<String> {
1229 let mut failures = Vec::new();
1230 for index in created {
1231 let Some(cycle) = outcomes[*index].cycle() else {
1232 continue;
1233 };
1234 if let Err(error) = store.delete(&cycle.name) {
1235 failures.push(format!("`{}`: {error}", cycle.name));
1236 }
1237 }
1238 for dir in missing_dirs {
1239 let empty = std::fs::read_dir(dir)
1240 .map(|mut entries| entries.next().is_none())
1241 .unwrap_or(false);
1242 if empty {
1243 let _ = std::fs::remove_dir(dir);
1244 }
1245 }
1246 if failures.is_empty() {
1247 None
1248 } else {
1249 Some(failures.join("; "))
1250 }
1251}
1252
1253#[cfg(test)]
1254mod tests {
1255 #[allow(clippy::wildcard_imports)]
1256 use super::*;
1257 use crate::cycle_schedule::config::{Cadence, CycleDuration, CycleScheduleExtensions};
1258 use crate::recurrence::rule::{IntervalUnit, RuleDiagnostic, RuleDiagnosticKind, Timezone};
1259
1260 fn schedule_id() -> CycleScheduleId {
1261 CycleScheduleId::new("cs-weekly").unwrap()
1262 }
1263
1264 fn schedule(duration: CycleDuration) -> CycleScheduleConfiguration {
1265 CycleScheduleConfiguration {
1266 name: "Weekly sprint".to_string(),
1267 cadence: Cadence::new(RuleKind::Weekly),
1268 duration,
1269 start_date: "2026-01-05".to_string(),
1270 timezone: Some(Timezone::parse("UTC")),
1271 look_ahead: 2,
1272 state: ScheduleState::Active,
1273 cursor: None,
1274 extensions: CycleScheduleExtensions::default(),
1275 }
1276 }
1277
1278 #[test]
1279 fn a_request_is_unconfirmed_until_it_says_so() {
1280 let context =
1281 RuleContext::on(Date::from_calendar_date(2026, time::Month::January, 5).unwrap());
1282 let request = GenerationRequest::new(schedule_id(), context.clone(), 2, "alice");
1283 assert!(!request.confirmed, "naming a schedule is not consent");
1284 let confirmed = request.clone().confirm();
1285 assert!(confirmed.confirmed);
1286 assert_eq!(confirmed.actor, "alice");
1287 assert_eq!(confirmed.look_ahead, 2);
1288 assert_eq!(confirmed.context, context);
1289 }
1290
1291 #[test]
1292 fn the_reference_label_keeps_its_discriminating_suffix_through_any_truncation() {
1293 assert_eq!(
1294 reference_name("Weekly sprint", "2026-01-05", 1),
1295 "Weekly sprint 2026-01-05 000001"
1296 );
1297 assert!(
1298 reference_name("Weekly sprint", "2026-01-05", 1)
1299 < reference_name("Weekly sprint", "2026-01-12", 2),
1300 "a canonical date keeps the labels in chronological order"
1301 );
1302
1303 let long = "x".repeat(500);
1307 let first = reference_name(&long, "2026-01-05", 1);
1308 let second = reference_name(&long, "2026-01-12", 2);
1309 assert_eq!(crate::slug::slugify(&first).len(), FILENAME_STEM_LIMIT);
1310 assert_ne!(crate::slug::slugify(&first), crate::slug::slugify(&second));
1311 assert!(instance_filename(&first).ends_with("-2026-01-05-000001.md"));
1312 assert!(instance_filename(&second).ends_with("-2026-01-12-000002.md"));
1313 assert_eq!(
1314 instance_filename(&first),
1315 instance_filename(&first),
1316 "the path a name becomes is deterministic"
1317 );
1318 for sequence in [1, 999_999] {
1319 let name = reference_name(&long, "2026-01-05", sequence);
1320 assert!(crate::slug::slugify(&name).len() <= FILENAME_STEM_LIMIT);
1321 assert!(instance_filename(&name).contains(&format!("{sequence:06}")));
1322 }
1323 }
1324
1325 #[test]
1326 fn every_outcome_has_one_stable_word() {
1327 let id = GeneratedCycleId::new(&schedule_id(), 1);
1328 let created = GenerationOutcome::Created {
1329 schedule_id: schedule_id(),
1330 key: id.clone(),
1331 cycle: GeneratedCycle {
1332 id: id.clone(),
1333 schedule_id: schedule_id(),
1334 sequence: 1,
1335 name: "Weekly sprint 2026-01-05".to_string(),
1336 start_date: "2026-01-05".to_string(),
1337 due_date: "2026-01-12".to_string(),
1338 state: GeneratedCycleState::Active,
1339 },
1340 };
1341 assert_eq!(created.as_str(), "created");
1342 assert!(created.is_created() && created.cycle().is_some());
1343 assert_eq!(created.key(), Some(&id));
1344 assert!(created.render().contains("2026-01-12"));
1345
1346 let noop = GenerationOutcome::NoOp {
1347 schedule_id: schedule_id(),
1348 key: id.clone(),
1349 state: GeneratedCycleState::Skipped,
1350 message: "already holds it".to_string(),
1351 };
1352 assert_eq!(noop.as_str(), "no-op");
1353 assert!(noop.is_noop());
1354 assert_eq!(noop.state(), Some(GeneratedCycleState::Skipped));
1355
1356 let refused = GenerationOutcome::refused(
1357 schedule_id(),
1358 Some(id.clone()),
1359 GenerationDiagnostic::new(
1360 GenerationDiagnosticKind::ReferenceCollision,
1361 "cycles.x",
1362 "already taken",
1363 ),
1364 );
1365 assert_eq!(refused.as_str(), "rejected");
1366 assert!(refused.is_rejected());
1367 assert!(refused.render().contains("reference_collision"));
1368 }
1369
1370 #[test]
1371 fn the_diagnostic_kinds_are_the_closed_vocabulary() {
1372 let spellings: Vec<&str> = [
1373 GenerationDiagnosticKind::NotConfirmed,
1374 GenerationDiagnosticKind::UnknownSchedule,
1375 GenerationDiagnosticKind::ScheduleNotActive,
1376 GenerationDiagnosticKind::MissingCadence,
1377 GenerationDiagnosticKind::InvalidCadence,
1378 GenerationDiagnosticKind::InvalidScheduleName,
1379 GenerationDiagnosticKind::InvalidDuration,
1380 GenerationDiagnosticKind::NoCandidate,
1381 GenerationDiagnosticKind::UnresolvableDate,
1382 GenerationDiagnosticKind::InvalidContext,
1383 GenerationDiagnosticKind::ReferenceCollision,
1384 GenerationDiagnosticKind::NameCollision,
1385 GenerationDiagnosticKind::SchedulingFailed,
1386 GenerationDiagnosticKind::CursorNotPersisted,
1387 ]
1388 .iter()
1389 .map(|kind| kind.as_str())
1390 .collect();
1391 assert_eq!(
1392 spellings,
1393 vec![
1394 "not_confirmed",
1395 "unknown_schedule",
1396 "schedule_not_active",
1397 "missing_cadence",
1398 "invalid_cadence",
1399 "invalid_schedule_name",
1400 "invalid_duration",
1401 "no_candidate",
1402 "unresolvable_date",
1403 "invalid_context",
1404 "reference_collision",
1405 "name_collision",
1406 "scheduling_failure",
1407 "cursor_not_persisted",
1408 ]
1409 );
1410 }
1411
1412 #[test]
1413 fn a_due_date_is_the_shared_owners_step_including_month_end_and_leap_days() {
1414 let monthly = schedule(CycleDuration::new(1, IntervalUnit::Months));
1416 assert_eq!(
1417 due_date(&schedule_id(), &monthly, "2026-01-31").unwrap(),
1418 "2026-02-28"
1419 );
1420 assert_eq!(
1422 due_date(&schedule_id(), &monthly, "2024-02-29").unwrap(),
1423 "2024-03-29"
1424 );
1425 let fortnight = schedule(CycleDuration::new(2, IntervalUnit::Weeks));
1427 assert_eq!(
1428 due_date(&schedule_id(), &fortnight, "2026-01-05").unwrap(),
1429 "2026-01-19"
1430 );
1431 }
1432
1433 #[test]
1434 fn a_duration_the_shared_owner_refuses_is_a_field_aware_refusal_at_the_users_field() {
1435 let huge = schedule(CycleDuration::new(1001, IntervalUnit::Weeks));
1438 let error = due_date(&schedule_id(), &huge, "2026-01-05").unwrap_err();
1439 assert_eq!(error.kind, GenerationDiagnosticKind::InvalidDuration);
1440 assert_eq!(error.field, "cycle_schedules.cs-weekly.duration.count");
1441 }
1442
1443 #[test]
1444 fn a_missing_duration_unit_and_a_zero_count_are_named_where_they_are_written() {
1445 let broken = schedule(CycleDuration {
1446 count: 1,
1447 unit: None,
1448 extensions: CycleScheduleExtensions::default(),
1449 });
1450 let error = due_date(&schedule_id(), &broken, "2026-01-05").unwrap_err();
1451 assert_eq!(error.field, "cycle_schedules.cs-weekly.duration.unit");
1452
1453 let zero = schedule(CycleDuration {
1454 count: 0,
1455 unit: Some(IntervalUnit::Weeks),
1456 extensions: CycleScheduleExtensions::default(),
1457 });
1458 let error = due_date(&schedule_id(), &zero, "2026-01-05").unwrap_err();
1459 assert_eq!(error.field, "cycle_schedules.cs-weekly.duration.count");
1460 }
1461
1462 #[test]
1463 fn rule_fields_are_translated_to_the_configuration_fields_they_came_from() {
1464 assert_eq!(cadence_field("anchor"), "start_date");
1465 assert_eq!(cadence_field("interval"), "cadence.interval");
1466 assert_eq!(duration_field("interval"), "count");
1467 assert_eq!(duration_field("unit"), "unit");
1468
1469 let diagnostic = RuleDiagnostic {
1472 kind: RuleDiagnosticKind::InvalidAnchor,
1473 field: "anchor",
1474 value: "2026-02-30".to_string(),
1475 message: "anchor: not a real date".to_string(),
1476 };
1477 assert_eq!(
1478 cadence_field(diagnostic.field),
1479 "start_date",
1480 "a cadence anchor is the schedule's start_date"
1481 );
1482 }
1483
1484 #[test]
1485 fn the_candidate_batch_is_the_next_key_then_shared_rule_steps() {
1486 let record = schedule(CycleDuration::new(1, IntervalUnit::Weeks));
1487 let rule = cadence_rule(&record).unwrap();
1488 let first = NextCycleKey {
1489 schedule_id: schedule_id(),
1490 sequence: 1,
1491 id: GeneratedCycleId::new(&schedule_id(), 1),
1492 start_date: Some("2026-01-05".to_string()),
1493 };
1494 let keys = candidate_keys(&rule, &first, 3);
1495 let dates: Vec<Option<String>> = keys.iter().map(|key| key.start_date.clone()).collect();
1496 assert_eq!(
1497 dates,
1498 vec![
1499 Some("2026-01-05".to_string()),
1500 Some("2026-01-12".to_string()),
1501 Some("2026-01-19".to_string()),
1502 ]
1503 );
1504 assert_eq!(keys[2].id.as_str(), "cs-weekly:000003");
1505 assert_eq!(
1506 candidate_keys(&rule, &first, 0),
1507 [] as [crate::cycle_schedule::identity::NextCycleKey; 0]
1508 );
1509 }
1510
1511 #[test]
1512 fn missing_ancestors_stops_at_the_first_directory_that_exists() {
1513 let missing = super::missing_ancestors(Path::new("/definitely/not/a/real/path/cycles"));
1514 assert_eq!(
1515 missing.first().map(PathBuf::as_path),
1516 Some(Path::new("/definitely/not/a/real/path/cycles"))
1517 );
1518 assert!(missing.len() > 1, "every missing ancestor is listed");
1519 assert!(
1520 missing.len() < 8,
1521 "the walk stops at the first existing directory"
1522 );
1523 }
1524}