1use std::collections::{BTreeMap, BTreeSet};
49use std::fmt;
50
51use indexmap::IndexMap;
52use serde::{Deserialize, Serialize};
53use time::{Date, Month};
54
55use crate::config::Config;
56use crate::cycle_schedule::config::{
57 CycleScheduleConfiguration, CycleScheduleExtensions, CycleScheduleId,
58};
59use crate::cycle_store::CycleRecord;
60use crate::error::{Result, TixError, render_diagnostics};
61use crate::recurrence::rule::{RecurrenceRule, RuleContext};
62
63#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
74#[serde(try_from = "String", into = "String")]
75pub struct GeneratedCycleId(String);
76
77impl GeneratedCycleId {
78 pub const SEQUENCE_WIDTH: usize = 6;
82
83 pub const SEQUENCE_LIMIT: u32 = 1_000_000;
90
91 #[must_use]
96 pub fn new(schedule: &CycleScheduleId, sequence: u32) -> Self {
97 debug_assert!(sequence > 0, "sequences start at 1");
98 Self(format!(
99 "{}:{:0width$}",
100 schedule.as_str(),
101 sequence,
102 width = Self::SEQUENCE_WIDTH
103 ))
104 }
105
106 pub fn parse(raw: &str) -> Result<Self> {
108 let (schedule, sequence) = raw.rsplit_once(':').ok_or_else(|| {
109 TixError::Config(format!(
110 "generated cycle id `{raw}` must be `<schedule-id>:<sequence>`"
111 ))
112 })?;
113 CycleScheduleId::new(schedule)
114 .map_err(|err| TixError::Config(format!("generated cycle id `{raw}`: {err}")))?;
115 if sequence.len() != Self::SEQUENCE_WIDTH || !sequence.bytes().all(|b| b.is_ascii_digit()) {
116 return Err(TixError::Config(format!(
117 "generated cycle id `{raw}` must carry a {}-digit sequence",
118 Self::SEQUENCE_WIDTH
119 )));
120 }
121 let parsed: u32 = sequence.parse().map_err(|_| {
122 TixError::Config(format!(
123 "generated cycle id `{raw}` has an unreadable sequence"
124 ))
125 })?;
126 if parsed == 0 {
127 return Err(TixError::Config(format!(
128 "generated cycle id `{raw}` has sequence 0; sequences start at 1"
129 )));
130 }
131 Ok(Self(raw.to_string()))
132 }
133
134 #[must_use]
136 pub fn as_str(&self) -> &str {
137 &self.0
138 }
139
140 #[must_use]
142 pub fn schedule_id(&self) -> &str {
143 self.0
144 .rsplit_once(':')
145 .map_or(self.0.as_str(), |(schedule, _)| schedule)
146 }
147
148 #[must_use]
150 pub fn sequence(&self) -> u32 {
151 self.0
152 .rsplit_once(':')
153 .and_then(|(_, sequence)| sequence.parse().ok())
154 .unwrap_or(0)
155 }
156
157 #[must_use]
159 pub fn belongs_to(&self, schedule: &CycleScheduleId) -> bool {
160 self.schedule_id() == schedule.as_str()
161 }
162}
163
164impl fmt::Display for GeneratedCycleId {
165 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166 f.write_str(&self.0)
167 }
168}
169
170impl TryFrom<String> for GeneratedCycleId {
171 type Error = TixError;
172
173 fn try_from(raw: String) -> Result<Self> {
174 Self::parse(&raw)
175 }
176}
177
178impl From<GeneratedCycleId> for String {
179 fn from(id: GeneratedCycleId) -> Self {
180 id.0
181 }
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
190#[serde(rename_all = "kebab-case")]
191pub enum GeneratedCycleState {
192 #[default]
194 Active,
195 Closed,
197 Skipped,
199 Paused,
201}
202
203impl GeneratedCycleState {
204 pub const fn as_str(self) -> &'static str {
206 match self {
207 GeneratedCycleState::Active => "active",
208 GeneratedCycleState::Closed => "closed",
209 GeneratedCycleState::Skipped => "skipped",
210 GeneratedCycleState::Paused => "paused",
211 }
212 }
213
214 pub fn parse(raw: &str) -> Result<Self> {
216 match raw {
217 "active" => Ok(Self::Active),
218 "closed" => Ok(Self::Closed),
219 "skipped" => Ok(Self::Skipped),
220 "paused" => Ok(Self::Paused),
221 other => Err(TixError::Config(format!(
222 "generated cycle state `{other}` is not one of `active`, `closed`, `skipped`, `paused`"
223 ))),
224 }
225 }
226
227 pub const fn is_terminal(self) -> bool {
229 matches!(self, Self::Closed | Self::Skipped)
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
238pub struct GeneratedCycle {
239 pub id: GeneratedCycleId,
241 pub schedule_id: CycleScheduleId,
243 pub sequence: u32,
245 pub name: String,
247 pub start_date: String,
249 pub due_date: String,
251 pub state: GeneratedCycleState,
253}
254
255impl GeneratedCycle {
256 pub fn from_record(record: &CycleRecord) -> Result<Option<Self>> {
263 let (Some(schedule_raw), Some(sequence), Some(state_raw)) = (
264 record.schedule_id.as_deref(),
265 record.sequence,
266 record.state.as_deref(),
267 ) else {
268 if record.schedule_id.is_none() && record.sequence.is_none() && record.state.is_none() {
269 return Ok(None);
270 }
271 return Err(TixError::Config(format!(
272 "cycle `{}` carries a partial generated identity: `schedule_id`, `sequence` \
273 and `state` are written together or not at all",
274 record.name
275 )));
276 };
277 if sequence == 0 || sequence >= GeneratedCycleId::SEQUENCE_LIMIT {
282 return Err(TixError::Config(format!(
283 "cycle `{}` carries generated sequence `{sequence}`; sequences start at 1 and \
284 must fit {} digits",
285 record.name,
286 GeneratedCycleId::SEQUENCE_WIDTH
287 )));
288 }
289 let (Some(start_date), Some(due_date)) =
293 (record.start_date.clone(), record.due_date.clone())
294 else {
295 return Err(TixError::Config(format!(
296 "generated cycle `{}` needs both `start_date` and `due_date`",
297 record.name
298 )));
299 };
300 let schedule_id = CycleScheduleId::new(schedule_raw)?;
301 let state = GeneratedCycleState::parse(state_raw)?;
302 let id = GeneratedCycleId::new(&schedule_id, sequence);
303 Ok(Some(Self {
304 id,
305 schedule_id,
306 sequence,
307 name: record.name.clone(),
308 start_date,
309 due_date,
310 state,
311 }))
312 }
313
314 #[must_use]
316 pub fn to_record(&self) -> CycleRecord {
317 CycleRecord {
318 name: self.name.clone(),
319 description: None,
320 start_date: Some(self.start_date.clone()),
321 due_date: Some(self.due_date.clone()),
322 schedule_id: Some(self.schedule_id.as_str().to_string()),
323 sequence: Some(self.sequence),
324 state: Some(self.state.as_str().to_string()),
325 }
326 }
327
328 #[must_use]
330 pub fn is_active(&self) -> bool {
331 self.state == GeneratedCycleState::Active
332 }
333}
334
335#[derive(Debug, Clone, PartialEq, Eq)]
340pub struct NextCycleKey {
341 pub schedule_id: CycleScheduleId,
343 pub sequence: u32,
345 pub id: GeneratedCycleId,
347 pub start_date: Option<String>,
351}
352
353#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
363#[serde(default)]
364pub struct GenerationCursor {
365 #[serde(skip_serializing_if = "Option::is_none")]
367 pub next_sequence: Option<u32>,
368 #[serde(skip_serializing_if = "Option::is_none")]
370 pub next_start: Option<String>,
371 #[serde(skip_serializing_if = "Option::is_none")]
373 pub last_generated_key: Option<GeneratedCycleId>,
374 #[serde(skip_serializing_if = "Option::is_none")]
376 pub look_ahead_applied_through: Option<u32>,
377 #[serde(flatten)]
379 pub extensions: CycleScheduleExtensions,
380}
381
382impl GenerationCursor {
383 #[must_use]
385 pub fn is_empty(&self) -> bool {
386 self == &Self::default()
387 }
388
389 pub const KNOWN_FIELDS: &'static [&'static str] = &[
391 "next_sequence",
392 "next_start",
393 "last_generated_key",
394 "look_ahead_applied_through",
395 ];
396}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
405pub enum CycleReference {
406 Legacy {
408 name: String,
410 },
411 Generated {
413 name: String,
415 id: GeneratedCycleId,
417 schedule_id: CycleScheduleId,
419 },
420 Unknown {
422 name: String,
424 },
425}
426
427#[derive(Debug, Clone, Copy, PartialEq, Eq)]
429pub enum IdentityDiagnosticKind {
430 MissingSchedule,
432 DuplicateSequence,
434 IdentityMismatch,
436 ReferenceCollision,
438 CursorDrift,
440 MalformedIdentity,
442}
443
444impl IdentityDiagnosticKind {
445 pub const fn as_str(self) -> &'static str {
447 match self {
448 IdentityDiagnosticKind::MissingSchedule => "missing_schedule",
449 IdentityDiagnosticKind::DuplicateSequence => "duplicate_sequence",
450 IdentityDiagnosticKind::IdentityMismatch => "identity_mismatch",
451 IdentityDiagnosticKind::ReferenceCollision => "reference_collision",
452 IdentityDiagnosticKind::CursorDrift => "cursor_drift",
453 IdentityDiagnosticKind::MalformedIdentity => "malformed_identity",
454 }
455 }
456}
457
458#[derive(Debug, Clone, PartialEq, Eq)]
463pub struct IdentityDiagnostic {
464 pub kind: IdentityDiagnosticKind,
466 pub schedule_id: Option<CycleScheduleId>,
468 pub cycle_id: Option<GeneratedCycleId>,
470 pub field: String,
472 pub message: String,
474}
475
476impl IdentityDiagnostic {
477 fn new(
479 kind: IdentityDiagnosticKind,
480 schedule_id: Option<CycleScheduleId>,
481 field: impl Into<String>,
482 message: impl Into<String>,
483 ) -> Self {
484 Self {
485 kind,
486 schedule_id,
487 cycle_id: None,
488 field: field.into(),
489 message: message.into(),
490 }
491 }
492
493 #[must_use]
495 fn on_cycle(mut self, id: GeneratedCycleId) -> Self {
496 self.cycle_id = Some(id);
497 self
498 }
499
500 #[must_use]
502 pub fn render(&self) -> String {
503 render_diagnostics(
504 "E-tix-cycle-schedule",
505 std::iter::once((self.kind.as_str(), self.message.as_str())),
506 )
507 }
508}
509
510#[derive(Debug, Clone, PartialEq, Eq)]
512struct IndexedCycle {
513 cycle: GeneratedCycle,
514}
515
516#[derive(Debug, Clone, Default)]
522pub struct ScheduleIndex {
523 schedules: IndexMap<CycleScheduleId, CycleScheduleConfiguration>,
524 by_name: BTreeMap<String, CycleScheduleId>,
525 cycles: BTreeMap<GeneratedCycleId, IndexedCycle>,
526 by_schedule: BTreeMap<CycleScheduleId, BTreeMap<u32, GeneratedCycleId>>,
527 by_reference: BTreeMap<String, GeneratedCycleId>,
528 legacy_names: BTreeSet<String>,
529 diagnostics: Vec<IdentityDiagnostic>,
530}
531
532impl ScheduleIndex {
533 #[must_use]
542 pub fn build(config: &Config, records: &[CycleRecord]) -> Self {
543 let mut index = Self::default();
544 for (raw, schedule) in &config.cycle_schedules {
545 let Ok(id) = CycleScheduleId::new(raw) else {
546 continue;
547 };
548 index
549 .by_name
550 .insert(schedule.name.trim().to_lowercase(), id.clone());
551 index.schedules.insert(id, schedule.clone());
552 }
553 for record in records {
554 index.add_record(record);
555 }
556 index.reconcile_cursors(config);
557 index
558 }
559
560 fn add_record(&mut self, record: &CycleRecord) {
562 let parsed = match GeneratedCycle::from_record(record) {
563 Ok(parsed) => parsed,
564 Err(err) => {
565 self.diagnostics.push(IdentityDiagnostic::new(
566 IdentityDiagnosticKind::MalformedIdentity,
567 None,
568 format!("cycles.{}", record.name),
569 err.to_string(),
570 ));
571 return;
572 }
573 };
574 let Some(cycle) = parsed else {
575 if let Some(existing) = self.by_reference.get(&record.name) {
579 self.diagnostics.push(
580 IdentityDiagnostic::new(
581 IdentityDiagnosticKind::ReferenceCollision,
582 None,
583 format!("cycles.{}", record.name),
584 format!(
585 "hand-written cycle `{}` and generated cycle `{existing}` both answer \
586 to the name `{}`; an issue reference would resolve to whichever is \
587 read first",
588 record.name, record.name
589 ),
590 )
591 .on_cycle(existing.clone()),
592 );
593 }
594 self.legacy_names.insert(record.name.clone());
595 return;
596 };
597 let schedule = cycle.schedule_id.clone();
598 let sequence = cycle.sequence;
599 let id = cycle.id.clone();
600
601 if !self.schedules.contains_key(&schedule) {
602 self.diagnostics.push(
603 IdentityDiagnostic::new(
604 IdentityDiagnosticKind::MissingSchedule,
605 Some(schedule.clone()),
606 format!("{id}.schedule_id"),
607 format!(
608 "generated cycle `{id}` names schedule `{schedule}`, which is not in \
609 `cycle_schedules`; the cycle is kept and reported rather than rebound"
610 ),
611 )
612 .on_cycle(id.clone()),
613 );
614 }
615 if id.sequence() != sequence {
616 self.diagnostics.push(
617 IdentityDiagnostic::new(
618 IdentityDiagnosticKind::IdentityMismatch,
619 Some(schedule.clone()),
620 format!("{id}.sequence"),
621 format!("generated cycle `{id}` disagrees with its own sequence `{sequence}`"),
622 )
623 .on_cycle(id.clone()),
624 );
625 }
626 if self
627 .by_schedule
628 .get(&schedule)
629 .is_some_and(|sequences| sequences.contains_key(&sequence))
630 {
631 self.diagnostics.push(
632 IdentityDiagnostic::new(
633 IdentityDiagnosticKind::DuplicateSequence,
634 Some(schedule.clone()),
635 format!("cycle_schedules.{schedule}.sequences.{sequence}"),
636 format!("two cycles claim sequence {sequence} of schedule `{schedule}`"),
637 )
638 .on_cycle(id.clone()),
639 );
640 }
641 let claimed_by_generated = self
646 .by_reference
647 .get(&cycle.name)
648 .filter(|other| *other != &id);
649 let claimed_by_legacy = self.legacy_names.contains(&cycle.name);
650 if claimed_by_generated.is_some() || claimed_by_legacy {
651 let other = match claimed_by_generated {
652 Some(existing) => format!("generated cycle `{existing}`"),
653 None => "a hand-written cycle".to_string(),
654 };
655 self.diagnostics.push(
656 IdentityDiagnostic::new(
657 IdentityDiagnosticKind::ReferenceCollision,
658 Some(schedule.clone()),
659 format!("cycles.{}", cycle.name),
660 format!(
661 "generated cycle `{id}` and {other} both answer to the name `{}`; an \
662 issue reference would resolve to whichever is read first",
663 cycle.name
664 ),
665 )
666 .on_cycle(id.clone()),
667 );
668 }
669
670 self.by_reference.insert(cycle.name.clone(), id.clone());
671 self.by_schedule
672 .entry(schedule)
673 .or_default()
674 .insert(sequence, id.clone());
675 self.cycles.insert(id, IndexedCycle { cycle });
676 }
677
678 fn reconcile_cursors(&mut self, config: &Config) {
680 for (id, schedule) in &config.cycle_schedules {
681 let Ok(schedule_id) = CycleScheduleId::new(id) else {
682 continue;
683 };
684 let Some(stored) = schedule.cursor.as_ref() else {
685 continue;
686 };
687 if stored.is_empty() {
691 continue;
692 }
693 let derived = self.derive_cursor(&schedule_id);
694 for (field, stored_value, derived_value) in [
695 (
696 "next_sequence",
697 stored.next_sequence.map(|v| v.to_string()),
698 derived.next_sequence.map(|v| v.to_string()),
699 ),
700 (
701 "next_start",
702 stored.next_start.clone(),
703 derived.next_start.clone(),
704 ),
705 (
706 "last_generated_key",
707 stored
708 .last_generated_key
709 .as_ref()
710 .map(|k| k.as_str().to_string()),
711 derived
712 .last_generated_key
713 .as_ref()
714 .map(|k| k.as_str().to_string()),
715 ),
716 (
717 "look_ahead_applied_through",
718 stored.look_ahead_applied_through.map(|v| v.to_string()),
719 derived.look_ahead_applied_through.map(|v| v.to_string()),
720 ),
721 ] {
722 if stored_value != derived_value {
723 self.diagnostics.push(IdentityDiagnostic::new(
724 IdentityDiagnosticKind::CursorDrift,
725 Some(schedule_id.clone()),
726 format!("cycle_schedules.{schedule_id}.cursor.{field}"),
727 format!(
728 "the stored cursor field `{field}` says {} but the generated cycles \
729 say {}; the instances are authoritative and the stored value is \
730 stale",
731 stored_value.as_deref().unwrap_or("nothing"),
732 derived_value.as_deref().unwrap_or("nothing")
733 ),
734 ));
735 }
736 }
737 }
738 }
739
740 pub fn schedules(
742 &self,
743 ) -> impl Iterator<Item = (&CycleScheduleId, &CycleScheduleConfiguration)> {
744 self.schedules.iter()
745 }
746
747 #[must_use]
749 pub fn schedule(&self, id: &CycleScheduleId) -> Option<&CycleScheduleConfiguration> {
750 self.schedules.get(id)
751 }
752
753 #[must_use]
755 pub fn schedule_by_name(&self, name: &str) -> Option<&CycleScheduleConfiguration> {
756 self.by_name
757 .get(&name.trim().to_lowercase())
758 .and_then(|id| self.schedules.get(id))
759 }
760
761 #[must_use]
763 pub fn schedule_count(&self) -> usize {
764 self.schedules.len()
765 }
766
767 #[must_use]
769 pub fn cycle(&self, id: &GeneratedCycleId) -> Option<&GeneratedCycle> {
770 self.cycles.get(id).map(|indexed| &indexed.cycle)
771 }
772
773 pub fn cycles_of(
775 &self,
776 schedule: &CycleScheduleId,
777 ) -> impl Iterator<Item = &GeneratedCycle> + '_ {
778 self.by_schedule
779 .get(schedule)
780 .into_iter()
781 .flat_map(|sequences| sequences.values())
782 .filter_map(|id| self.cycle(id))
783 }
784
785 #[must_use]
787 pub fn generated_count(&self) -> usize {
788 self.cycles.len()
789 }
790
791 #[must_use]
793 pub fn is_consistent(&self) -> bool {
794 self.diagnostics.is_empty()
795 }
796
797 #[must_use]
799 pub fn diagnostics(&self) -> &[IdentityDiagnostic] {
800 &self.diagnostics
801 }
802
803 #[must_use]
815 pub fn resolve(&self, name: &str) -> CycleReference {
816 if let Some(id) = self.by_reference.get(name)
817 && let Some(cycle) = self.cycle(id)
818 {
819 return CycleReference::Generated {
820 name: name.to_string(),
821 id: id.clone(),
822 schedule_id: cycle.schedule_id.clone(),
823 };
824 }
825 if self.legacy_names.contains(name) {
826 return CycleReference::Legacy {
827 name: name.to_string(),
828 };
829 }
830 CycleReference::Unknown {
831 name: name.to_string(),
832 }
833 }
834
835 #[must_use]
846 pub fn cursor(&self, schedule: &CycleScheduleId) -> GenerationCursor {
847 self.derive_cursor(schedule)
848 }
849
850 fn derive_cursor(&self, schedule: &CycleScheduleId) -> GenerationCursor {
852 let last = self
853 .by_schedule
854 .get(schedule)
855 .and_then(|sequences| sequences.iter().next_back());
856 let Some((sequence, id)) = last else {
857 return GenerationCursor {
858 next_sequence: Some(1),
859 next_start: self
860 .schedules
861 .get(schedule)
862 .map(|record| record.start_date.clone()),
863 last_generated_key: None,
864 look_ahead_applied_through: None,
865 extensions: CycleScheduleExtensions::default(),
866 };
867 };
868 let Some(cycle) = self.cycle(id) else {
869 return GenerationCursor::default();
870 };
871 GenerationCursor {
872 next_sequence: Some(sequence.saturating_add(1)),
873 next_start: self.next_start_after(schedule, &cycle.start_date),
874 last_generated_key: Some(id.clone()),
875 look_ahead_applied_through: Some(*sequence),
876 extensions: CycleScheduleExtensions::default(),
877 }
878 }
879
880 fn next_start_after(&self, schedule: &CycleScheduleId, after: &str) -> Option<String> {
886 let record = self.schedules.get(schedule)?;
887 let rule = record.cadence.to_rule(record)?;
888 let date = date_value(after)?;
889 rule.next_occurrence(&RuleContext::on(date))
890 .ok()
891 .map(|key| key.as_str().to_string())
892 }
893
894 #[must_use]
897 pub fn next_key(&self, schedule: &CycleScheduleId) -> Option<NextCycleKey> {
898 let record = self.schedules.get(schedule)?;
899 if record.state.is_terminal() {
900 return None;
901 }
902 let cursor = self.cursor(schedule);
903 let sequence = cursor.next_sequence?;
904 if sequence >= GeneratedCycleId::SEQUENCE_LIMIT {
908 return None;
909 }
910 Some(NextCycleKey {
911 id: GeneratedCycleId::new(schedule, sequence),
912 sequence,
913 start_date: cursor.next_start.clone(),
914 schedule_id: schedule.clone(),
915 })
916 }
917}
918
919fn date_value(value: &str) -> Option<Date> {
926 if !crate::model::validate_date(value) {
927 return None;
928 }
929 let year: i32 = value.get(0..4)?.parse().ok()?;
930 let month: u8 = value.get(5..7)?.parse().ok()?;
931 let day: u8 = value.get(8..10)?.parse().ok()?;
932 Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()
933}
934
935#[must_use]
938pub fn cadence_rule(record: &CycleScheduleConfiguration) -> Option<RecurrenceRule> {
939 record.cadence.to_rule(record)
940}
941
942#[cfg(test)]
943mod tests {
944 #[allow(clippy::wildcard_imports)]
945 use super::*;
946
947 fn schedule_id() -> CycleScheduleId {
948 CycleScheduleId::new("cs-test").unwrap()
949 }
950
951 #[test]
952 fn a_generated_id_round_trips_and_sorts_by_sequence() {
953 let schedule = schedule_id();
954 let first = GeneratedCycleId::new(&schedule, 1);
955 let tenth = GeneratedCycleId::new(&schedule, 10);
956 assert_eq!(first.as_str(), "cs-test:000001");
957 assert_eq!(first.sequence(), 1);
958 assert_eq!(first.schedule_id(), "cs-test");
959 assert!(first.belongs_to(&schedule));
960 assert!(
961 first < tenth,
962 "a fixed width is what keeps plain-string order"
963 );
964
965 let parsed = GeneratedCycleId::parse(first.as_str()).unwrap();
966 assert_eq!(parsed, first);
967 }
968
969 #[test]
970 fn a_schedule_id_may_itself_contain_a_colon() {
971 let schedule = CycleScheduleId::new("cs-1:2").unwrap();
972 let id = GeneratedCycleId::new(&schedule, 7);
973 assert_eq!(id.as_str(), "cs-1:2:000007");
974 assert_eq!(id.schedule_id(), "cs-1:2");
975 assert_eq!(id.sequence(), 7);
976 assert_eq!(GeneratedCycleId::parse(id.as_str()).unwrap(), id);
977 }
978
979 #[test]
980 fn the_sequence_limit_is_exactly_the_field_width() {
981 assert_eq!(
982 GeneratedCycleId::SEQUENCE_LIMIT,
983 10u32.pow(GeneratedCycleId::SEQUENCE_WIDTH as u32),
984 "the limit must be the first sequence the field cannot hold"
985 );
986 let schedule = schedule_id();
987 let last = GeneratedCycleId::new(&schedule, GeneratedCycleId::SEQUENCE_LIMIT - 1);
988 assert_eq!(
989 GeneratedCycleId::parse(last.as_str()).unwrap(),
990 last,
991 "the highest nameable sequence must round-trip"
992 );
993 assert_eq!(last.as_str(), "cs-test:999999");
994 }
995
996 #[test]
997 fn a_malformed_id_is_refused_with_the_shape_it_wanted() {
998 for raw in [
999 "cs-test",
1000 "cs-test:1",
1001 "cs-test:000000",
1002 "cs-test:00000a",
1003 "cs-test:1000000",
1004 ] {
1005 let error = GeneratedCycleId::parse(raw).err();
1006 assert!(error.is_some(), "`{raw}` must be refused");
1007 }
1008 }
1009
1010 #[test]
1011 fn a_partial_identity_on_a_record_is_refused_rather_than_half_read() {
1012 let record = CycleRecord {
1013 name: "Sprint 1".into(),
1014 sequence: Some(1),
1015 ..CycleRecord::default()
1016 };
1017 let error =
1018 GeneratedCycle::from_record(&record).expect_err("a partial identity is refused");
1019 assert!(error.to_string().contains("written together"), "{error}");
1020
1021 let legacy = CycleRecord {
1022 name: "Cycle 1".into(),
1023 ..CycleRecord::default()
1024 };
1025 assert!(GeneratedCycle::from_record(&legacy).unwrap().is_none());
1026 }
1027}