1use std::collections::{BTreeMap, BTreeSet};
41use std::fmt;
42
43use serde::{Deserialize, Serialize};
44use time::{Date, Month};
45
46use crate::error::render_diagnostics;
47use crate::model::{Issue, SeriesRef, SeriesRule};
48use crate::recurrence::rule::{DateKey, IntervalUnit, RecurrenceRule, RuleKind, Timezone};
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub enum SeriesRole {
53 Definition,
55 Occurrence,
57}
58
59impl SeriesRef {
60 pub fn role(&self) -> SeriesRole {
66 if self.rule.is_some() {
67 SeriesRole::Definition
68 } else {
69 SeriesRole::Occurrence
70 }
71 }
72}
73
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
80#[serde(rename_all = "kebab-case")]
81pub enum SeriesLifecycle {
82 #[default]
84 Active,
85 Paused,
87 Ended,
89}
90
91impl SeriesLifecycle {
92 pub const fn as_str(self) -> &'static str {
94 match self {
95 Self::Active => "active",
96 Self::Paused => "paused",
97 Self::Ended => "ended",
98 }
99 }
100
101 pub const fn allows_generation(self) -> bool {
104 matches!(self, Self::Active)
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
117#[serde(transparent)]
118pub struct SeriesId(String);
119
120impl SeriesId {
121 pub fn parse(raw: &str) -> Result<Self, SeriesError> {
123 let trimmed = raw.trim();
124 if trimmed.is_empty() {
125 return Err(SeriesError::single(SeriesDiagnostic::new(
126 SeriesDiagnosticKind::InvalidSeriesId,
127 "series.id",
128 "a series id cannot be empty",
129 )));
130 }
131 if !trimmed
132 .bytes()
133 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
134 {
135 return Err(SeriesError::single(
136 SeriesDiagnostic::new(
137 SeriesDiagnosticKind::InvalidSeriesId,
138 "series.id",
139 "a series id may hold only ASCII letters, digits, `-`, `_` and `.`",
140 )
141 .with_value(raw),
142 ));
143 }
144 Ok(Self(trimmed.to_string()))
145 }
146
147 pub fn generate() -> Self {
149 Self(format!("s-{}", crate::id::generate_issue_id()))
150 }
151
152 pub fn as_str(&self) -> &str {
154 &self.0
155 }
156}
157
158impl fmt::Display for SeriesId {
159 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160 f.write_str(&self.0)
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
172#[serde(transparent)]
173pub struct OccurrenceKey(String);
174
175impl OccurrenceKey {
176 pub fn new(series: &SeriesId, date: &DateKey) -> Self {
178 Self(format!("{}@{}", series.as_str(), date.as_str()))
179 }
180
181 pub fn as_str(&self) -> &str {
183 &self.0
184 }
185
186 pub fn parse(raw: &str) -> Result<(SeriesId, DateKey), SeriesError> {
191 let Some((id, date)) = raw.split_once('@') else {
192 return Err(SeriesError::single(
193 SeriesDiagnostic::new(
194 SeriesDiagnosticKind::MalformedKey,
195 "series.key",
196 "a key is `<series-id>@<YYYY-MM-DD>`",
197 )
198 .with_value(raw),
199 ));
200 };
201 let id = SeriesId::parse(id)?;
202 let date = parse_date_key(date).ok_or_else(|| {
203 SeriesError::single(
204 SeriesDiagnostic::new(
205 SeriesDiagnosticKind::MalformedKey,
206 "series.key",
207 "the date half must be a real `YYYY-MM-DD` calendar date",
208 )
209 .with_value(raw),
210 )
211 })?;
212 Ok((id, date))
213 }
214}
215
216impl fmt::Display for OccurrenceKey {
217 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218 f.write_str(&self.0)
219 }
220}
221
222#[derive(Debug, Clone, PartialEq)]
227pub struct Series {
228 pub id: SeriesId,
230 pub name: String,
232 pub rule: RecurrenceRule,
234 pub state: SeriesLifecycle,
236 pub next_cursor: Option<DateKey>,
238 pub skipped: BTreeSet<DateKey>,
240 pub issue_number: u32,
242 pub filename: String,
244}
245
246impl Series {
247 pub fn from_ref(
255 reference: &SeriesRef,
256 issue_number: u32,
257 filename: &str,
258 ) -> Result<Option<Self>, SeriesError> {
259 if reference.role() != SeriesRole::Definition {
260 return Ok(None);
261 }
262 let mut diagnostics: Vec<SeriesDiagnostic> = Vec::new();
263 let id = match SeriesId::parse(&reference.id) {
264 Ok(id) => Some(id),
265 Err(error) => {
266 diagnostics.extend(error.diagnostics().iter().cloned());
267 None
268 }
269 };
270 let name = reference.name.clone().unwrap_or_default();
271 if name.trim().is_empty() {
272 diagnostics.push(SeriesDiagnostic::new(
273 SeriesDiagnosticKind::MissingField,
274 "series.name",
275 "a series definition needs a display name (`series.name`)",
276 ));
277 }
278 let stored = reference
279 .rule
280 .as_ref()
281 .expect("`role()` says a rule is set");
282 let rule = match typed_rule(stored) {
283 Ok(rule) => Some(rule),
284 Err(error) => {
285 diagnostics.extend(error.diagnostics().iter().cloned());
286 None
287 }
288 };
289 if let Some(rule) = rule.as_ref()
290 && let Err(error) = rule.validate()
291 {
292 diagnostics.extend(error.diagnostics().iter().map(|diagnostic| {
293 SeriesDiagnostic::new(
294 SeriesDiagnosticKind::InvalidRule,
295 format!("series.rule.{}", diagnostic.field),
296 diagnostic.message.clone(),
297 )
298 }));
299 }
300 if !diagnostics.is_empty() {
301 return Err(SeriesError::new(diagnostics));
302 }
303 Ok(Some(Self {
304 id: id.expect("an unusable id is a diagnostic"),
305 name,
306 rule: rule.expect("a rule that does not build is a diagnostic"),
307 state: reference.state.unwrap_or_default(),
308 next_cursor: reference.next_cursor.as_deref().and_then(parse_date_key),
309 skipped: reference
310 .skipped
311 .iter()
312 .filter_map(|raw| parse_date_key(raw))
313 .collect(),
314 issue_number,
315 filename: filename.to_string(),
316 }))
317 }
318
319 pub const fn allows_generation(&self) -> bool {
321 self.state.allows_generation()
322 }
323
324 pub fn key_for(&self, date: &DateKey) -> OccurrenceKey {
326 OccurrenceKey::new(&self.id, date)
327 }
328
329 pub fn is_skipped(&self, date: &DateKey) -> bool {
331 self.skipped.contains(date)
332 }
333}
334
335#[derive(Debug, Clone, PartialEq)]
337pub struct OccurrenceRecord {
338 pub series_id: SeriesId,
340 pub date: DateKey,
342 pub key: OccurrenceKey,
344 pub issue_number: u32,
346 pub filename: String,
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
352pub enum KeyState {
353 Generated,
355 Skipped,
357 Free,
359}
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
363pub enum SeriesDiagnosticKind {
364 InvalidSeriesId,
366 DuplicateSeriesId,
368 MissingField,
370 InvalidRule,
372 MalformedKey,
374 KeySeriesMismatch,
376 KeyDateMismatch,
378 MissingOccurrenceKey,
380 DuplicateOccurrenceKey,
382 SeriesIsItsOwnOccurrence,
384}
385
386impl SeriesDiagnosticKind {
387 pub const fn as_str(self) -> &'static str {
389 match self {
390 Self::InvalidSeriesId => "invalid_series_id",
391 Self::DuplicateSeriesId => "duplicate_series_id",
392 Self::MissingField => "missing_series_field",
393 Self::InvalidRule => "invalid_series_rule",
394 Self::MalformedKey => "malformed_occurrence_key",
395 Self::KeySeriesMismatch => "occurrence_key_series_mismatch",
396 Self::KeyDateMismatch => "occurrence_key_date_mismatch",
397 Self::MissingOccurrenceKey => "missing_occurrence_key",
398 Self::DuplicateOccurrenceKey => "duplicate_occurrence_key",
399 Self::SeriesIsItsOwnOccurrence => "series_is_its_own_occurrence",
400 }
401 }
402}
403
404#[derive(Debug, Clone, PartialEq, Eq)]
406pub struct SeriesDiagnostic {
407 pub kind: SeriesDiagnosticKind,
409 pub issue_number: u32,
411 pub filename: String,
413 pub field: String,
415 pub message: String,
417}
418
419impl SeriesDiagnostic {
420 pub fn new(
422 kind: SeriesDiagnosticKind,
423 field: impl Into<String>,
424 message: impl Into<String>,
425 ) -> Self {
426 Self {
427 kind,
428 issue_number: 0,
429 filename: String::new(),
430 field: field.into(),
431 message: message.into(),
432 }
433 }
434
435 pub fn with_value(mut self, raw: &str) -> Self {
437 self.message = format!("{} (got `{raw}`)", self.message);
438 self
439 }
440
441 pub fn on(mut self, issue_number: u32, filename: &str) -> Self {
443 self.issue_number = issue_number;
444 self.filename = filename.to_string();
445 self
446 }
447
448 pub fn render(&self) -> String {
451 render_diagnostics(
452 "E-tix-series",
453 std::iter::once((self.kind.as_str(), self.message.as_str())),
454 )
455 }
456}
457
458#[derive(Debug, Clone, PartialEq, Eq)]
460pub struct SeriesError {
461 diagnostics: Vec<SeriesDiagnostic>,
462}
463
464impl SeriesError {
465 pub fn new(diagnostics: Vec<SeriesDiagnostic>) -> Self {
467 Self { diagnostics }
468 }
469
470 pub fn single(diagnostic: SeriesDiagnostic) -> Self {
472 Self {
473 diagnostics: vec![diagnostic],
474 }
475 }
476
477 pub fn diagnostics(&self) -> &[SeriesDiagnostic] {
479 &self.diagnostics
480 }
481
482 pub fn first(&self) -> &SeriesDiagnostic {
484 &self.diagnostics[0]
485 }
486}
487
488impl fmt::Display for SeriesError {
489 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
490 f.write_str(&self.first().message)
491 }
492}
493
494#[derive(Debug, Clone, Default, PartialEq)]
498pub struct SeriesIndex {
499 series: BTreeMap<SeriesId, Series>,
500 occurrences: BTreeMap<OccurrenceKey, OccurrenceRecord>,
501 diagnostics: Vec<SeriesDiagnostic>,
502}
503
504impl SeriesIndex {
505 pub fn build(issues: &[Issue]) -> Self {
511 let mut index = Self::default();
512 for issue in issues {
513 if let Some(reference) = issue.series.as_ref() {
514 index.absorb(issue, reference);
515 }
516 }
517 index.check_cycles(issues);
518 index
519 }
520
521 fn absorb(&mut self, issue: &Issue, reference: &SeriesRef) {
522 match reference.role() {
523 SeriesRole::Definition => self.absorb_definition(issue, reference),
524 SeriesRole::Occurrence => self.absorb_occurrence(issue, reference),
525 }
526 }
527
528 fn absorb_definition(&mut self, issue: &Issue, reference: &SeriesRef) {
529 match Series::from_ref(reference, issue.number, &issue.filename) {
530 Ok(Some(series)) => {
531 if let Some(previous) = self.series.get(&series.id) {
532 self.diagnostics.push(
533 SeriesDiagnostic::new(
534 SeriesDiagnosticKind::DuplicateSeriesId,
535 "series.id",
536 format!(
537 "series `{}` is already defined by {}; two issues cannot share \
538 one series id",
539 series.id, previous.filename
540 ),
541 )
542 .on(issue.number, &issue.filename),
543 );
544 return;
545 }
546 self.series.insert(series.id.clone(), series);
547 }
548 Ok(None) => {}
549 Err(error) => self.diagnostics.extend(
550 error
551 .diagnostics()
552 .iter()
553 .cloned()
554 .map(|diagnostic| diagnostic.on(issue.number, &issue.filename)),
555 ),
556 }
557 }
558
559 fn absorb_occurrence(&mut self, issue: &Issue, reference: &SeriesRef) {
560 let Some(raw_key) = reference.key.as_deref() else {
561 self.diagnostics.push(
562 SeriesDiagnostic::new(
563 SeriesDiagnosticKind::MissingOccurrenceKey,
564 "series.key",
565 "an occurrence needs the key it was generated for \
566 (`<series-id>@<YYYY-MM-DD>`)",
567 )
568 .on(issue.number, &issue.filename),
569 );
570 return;
571 };
572 let (series_id, date) = match OccurrenceKey::parse(raw_key) {
573 Ok(parts) => parts,
574 Err(error) => {
575 self.diagnostics.extend(
576 error
577 .diagnostics()
578 .iter()
579 .cloned()
580 .map(|diagnostic| diagnostic.on(issue.number, &issue.filename)),
581 );
582 return;
583 }
584 };
585 if series_id.as_str() != reference.id {
586 self.diagnostics.push(
587 SeriesDiagnostic::new(
588 SeriesDiagnosticKind::KeySeriesMismatch,
589 "series.key",
590 format!(
591 "the key names series `{series_id}` but `series.id` is `{}`",
592 reference.id
593 ),
594 )
595 .on(issue.number, &issue.filename),
596 );
597 }
598 if let Some(declared) = reference.date.as_deref()
599 && declared != date.as_str()
600 {
601 self.diagnostics.push(
602 SeriesDiagnostic::new(
603 SeriesDiagnosticKind::KeyDateMismatch,
604 "series.date",
605 format!("`series.date` is `{declared}` but the key names `{date}`"),
606 )
607 .on(issue.number, &issue.filename),
608 );
609 }
610 let key = OccurrenceKey::new(&series_id, &date);
611 if let Some(previous) = self.occurrences.get(&key) {
612 self.diagnostics.push(
613 SeriesDiagnostic::new(
614 SeriesDiagnosticKind::DuplicateOccurrenceKey,
615 "series.key",
616 format!(
617 "occurrence `{key}` already belongs to {}; one key is one occurrence",
618 previous.filename
619 ),
620 )
621 .on(issue.number, &issue.filename),
622 );
623 return;
624 }
625 self.occurrences.insert(
626 key.clone(),
627 OccurrenceRecord {
628 series_id,
629 date,
630 key,
631 issue_number: issue.number,
632 filename: issue.filename.clone(),
633 },
634 );
635 }
636
637 fn check_cycles(&mut self, issues: &[Issue]) {
645 for issue in issues {
646 let Some(reference) = issue.series.as_ref() else {
647 continue;
648 };
649 if reference.role() != SeriesRole::Definition {
650 continue;
651 }
652 let Some(raw_key) = reference.key.as_deref() else {
653 continue;
654 };
655 self.diagnostics.push(
656 SeriesDiagnostic::new(
657 SeriesDiagnosticKind::SeriesIsItsOwnOccurrence,
658 "series.key",
659 format!(
660 "issue {} defines series `{}` and also carries the occurrence key \
661 `{raw_key}`; a definition cannot be an occurrence of the series it \
662 would generate",
663 issue.number, reference.id
664 ),
665 )
666 .on(issue.number, &issue.filename),
667 );
668 }
669 }
670
671 pub fn series(&self) -> impl Iterator<Item = &Series> {
673 self.series.values()
674 }
675
676 pub fn series_by_id(&self, id: &SeriesId) -> Option<&Series> {
678 self.series.get(id)
679 }
680
681 pub fn series_count(&self) -> usize {
683 self.series.len()
684 }
685
686 pub fn occurrence(&self, key: &OccurrenceKey) -> Option<&OccurrenceRecord> {
688 self.occurrences.get(key)
689 }
690
691 pub fn occurrences(&self) -> impl Iterator<Item = &OccurrenceRecord> {
694 self.occurrences.values()
695 }
696
697 pub fn key_state(&self, key: &OccurrenceKey) -> KeyState {
699 if self.occurrences.contains_key(key) {
700 return KeyState::Generated;
701 }
702 if let Ok((id, date)) = OccurrenceKey::parse(key.as_str())
703 && self
704 .series
705 .get(&id)
706 .is_some_and(|series| series.is_skipped(&date))
707 {
708 return KeyState::Skipped;
709 }
710 KeyState::Free
711 }
712
713 pub fn is_taken(&self, key: &OccurrenceKey) -> bool {
717 self.key_state(key) != KeyState::Free
718 }
719
720 pub fn diagnostics(&self) -> &[SeriesDiagnostic] {
722 &self.diagnostics
723 }
724
725 pub fn is_consistent(&self) -> bool {
727 self.diagnostics.is_empty()
728 }
729}
730
731fn typed_rule(stored: &SeriesRule) -> Result<RecurrenceRule, SeriesError> {
740 let mut diagnostics = Vec::new();
741 let kind = match stored.kind.as_deref().map(str::trim) {
742 Some(raw) => match parse_kind(raw) {
743 Some(kind) => Some(kind),
744 None => {
745 diagnostics.push(
746 SeriesDiagnostic::new(
747 SeriesDiagnosticKind::InvalidRule,
748 "series.rule.kind",
749 "the rule kind must be one of `daily`, `weekly`, `monthly`, `interval`, `cron-like`",
750 )
751 .with_value(raw),
752 );
753 None
754 }
755 },
756 None => {
757 diagnostics.push(SeriesDiagnostic::new(
758 SeriesDiagnosticKind::InvalidRule,
759 "series.rule.kind",
760 "a series rule needs a kind (`series.rule.kind`)",
761 ));
762 None
763 }
764 };
765 let unit = match stored.unit.as_deref().map(str::trim) {
766 Some(raw) => match parse_unit(raw) {
767 Some(unit) => Some(unit),
768 None => {
769 diagnostics.push(
770 SeriesDiagnostic::new(
771 SeriesDiagnosticKind::InvalidRule,
772 "series.rule.unit",
773 "the unit must be one of `days`, `weeks`, `months`",
774 )
775 .with_value(raw),
776 );
777 None
778 }
779 },
780 None => None,
781 };
782 if !diagnostics.is_empty() {
783 return Err(SeriesError::new(diagnostics));
784 }
785 Ok(RecurrenceRule {
786 kind: kind.expect("a missing kind is a diagnostic"),
787 interval: stored.interval,
788 anchor: stored.anchor.clone(),
789 timezone: stored.timezone.as_deref().map(Timezone::parse),
790 expression: stored.expression.clone(),
791 unit,
792 extensions: stored
793 .extensions
794 .iter()
795 .map(|(key, value)| (key.clone(), serde_yaml::Value::String(value.clone())))
796 .collect(),
797 })
798}
799
800fn parse_kind(raw: &str) -> Option<RuleKind> {
803 match raw {
804 "daily" => Some(RuleKind::Daily),
805 "weekly" => Some(RuleKind::Weekly),
806 "monthly" => Some(RuleKind::Monthly),
807 "interval" => Some(RuleKind::Interval),
808 "cron-like" => Some(RuleKind::CronLike),
809 _ => None,
810 }
811}
812
813fn parse_unit(raw: &str) -> Option<IntervalUnit> {
816 match raw {
817 "days" => Some(IntervalUnit::Days),
818 "weeks" => Some(IntervalUnit::Weeks),
819 "months" => Some(IntervalUnit::Months),
820 _ => None,
821 }
822}
823
824fn parse_date_key(raw: &str) -> Option<DateKey> {
830 if !crate::model::validate_date(raw) {
831 return None;
832 }
833 let year: i32 = raw.get(0..4)?.parse().ok()?;
834 let month: u8 = raw.get(5..7)?.parse().ok()?;
835 let day: u8 = raw.get(8..10)?.parse().ok()?;
836 let date = Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()?;
837 Some(DateKey::from_date(date))
838}
839
840#[cfg(test)]
841mod tests {
842 #[allow(clippy::wildcard_imports)]
843 use super::*;
844
845 fn issue(number: u32, filename: &str, series: &str) -> Issue {
848 let contents = format!(
849 "---\nnumber: {number}\ntitle: \"Fixture {number}\"\nstatus: \"todo\"\n\
850 created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n{series}---\n\nBody.\n"
851 );
852 let mut issue = Issue::parse(&contents).expect("the fixture parses");
853 issue.filename = filename.to_string();
854 issue
855 }
856
857 fn date(raw: &str) -> DateKey {
858 parse_date_key(raw).expect("a real date")
859 }
860
861 fn key(raw: &str) -> OccurrenceKey {
864 let (id, date) = OccurrenceKey::parse(raw).expect("a well-formed key");
865 OccurrenceKey::new(&id, &date)
866 }
867
868 fn definition(number: u32, filename: &str, id: &str, extra: &str) -> Issue {
869 issue(
870 number,
871 filename,
872 &format!(
873 "series:\n id: {id}\n name: Nightly\n rule:\n kind: daily\n \
874 anchor: 2026-01-01\n{extra}"
875 ),
876 )
877 }
878
879 fn occurrence(number: u32, filename: &str, id: &str, key: &str, date: &str) -> Issue {
880 issue(
881 number,
882 filename,
883 &format!("series:\n id: {id}\n date: {date}\n key: {key}\n"),
884 )
885 }
886
887 #[test]
888 fn a_key_is_the_series_and_the_date() {
889 let id = SeriesId::parse("s-abc").unwrap();
890 let key = OccurrenceKey::new(&id, &date("2026-02-28"));
891 assert_eq!(key.as_str(), "s-abc@2026-02-28");
892 let (parsed_id, parsed_date) = OccurrenceKey::parse(key.as_str()).unwrap();
893 assert_eq!(parsed_id, id);
894 assert_eq!(parsed_date, date("2026-02-28"));
895 }
896
897 #[test]
898 fn a_malformed_key_names_what_a_key_is() {
899 assert_eq!(
900 OccurrenceKey::parse("no-separator")
901 .unwrap_err()
902 .first()
903 .kind,
904 SeriesDiagnosticKind::MalformedKey
905 );
906 assert_eq!(
908 OccurrenceKey::parse("s-abc@2026-02-30")
909 .unwrap_err()
910 .first()
911 .kind,
912 SeriesDiagnosticKind::MalformedKey
913 );
914 }
915
916 #[test]
917 fn an_unsafe_id_is_refused() {
918 assert_eq!(
919 SeriesId::parse("").unwrap_err().first().kind,
920 SeriesDiagnosticKind::InvalidSeriesId
921 );
922 assert_eq!(
923 SeriesId::parse("s abc").unwrap_err().first().kind,
924 SeriesDiagnosticKind::InvalidSeriesId
925 );
926 assert_eq!(SeriesId::parse("s-abc_1.2").unwrap().as_str(), "s-abc_1.2");
927 assert!(SeriesId::generate().as_str().starts_with("s-"));
928 }
929
930 #[test]
931 fn the_index_separates_definitions_from_occurrences() {
932 let issues = vec![
933 definition(1, "nightly.md", "s-abc", ""),
934 occurrence(2, "a.md", "s-abc", "s-abc@2026-01-01", "2026-01-01"),
935 ];
936 let index = SeriesIndex::build(&issues);
937 assert!(index.is_consistent(), "{:?}", index.diagnostics());
938 assert_eq!(index.series_count(), 1);
939 assert_eq!(index.occurrences().count(), 1);
940 assert_eq!(
941 index.key_state(&key("s-abc@2026-01-01")),
942 KeyState::Generated
943 );
944 assert_eq!(index.key_state(&key("s-abc@2026-01-02")), KeyState::Free);
945 }
946
947 #[test]
948 fn a_duplicate_series_id_is_reported_and_the_first_definition_wins() {
949 let issues = vec![
950 definition(1, "first.md", "s-abc", ""),
951 definition(2, "second.md", "s-abc", ""),
952 ];
953 let index = SeriesIndex::build(&issues);
954 assert_eq!(index.series_count(), 1);
955 assert_eq!(
956 index.diagnostics()[0].kind,
957 SeriesDiagnosticKind::DuplicateSeriesId
958 );
959 assert_eq!(
960 index
961 .series_by_id(&SeriesId::parse("s-abc").unwrap())
962 .unwrap()
963 .issue_number,
964 1
965 );
966 }
967
968 #[test]
969 fn a_key_that_contradicts_its_declared_series_or_date_is_reported() {
970 let issues = vec![
971 definition(1, "nightly.md", "s-abc", ""),
972 occurrence(2, "a.md", "s-abc", "s-xyz@2026-01-01", "2026-01-01"),
973 occurrence(3, "b.md", "s-abc", "s-abc@2026-01-02", "2026-01-03"),
974 ];
975 let index = SeriesIndex::build(&issues);
976 let kinds: Vec<_> = index.diagnostics().iter().map(|d| d.kind).collect();
977 assert!(
978 kinds.contains(&SeriesDiagnosticKind::KeySeriesMismatch),
979 "{kinds:?}"
980 );
981 assert!(
982 kinds.contains(&SeriesDiagnosticKind::KeyDateMismatch),
983 "{kinds:?}"
984 );
985 }
986
987 #[test]
988 fn an_occurrence_without_a_key_is_reported_rather_than_guessed() {
989 let broken = issue(2, "a.md", "series:\n id: s-abc\n date: 2026-01-01\n");
990 let index = SeriesIndex::build(&[broken]);
991 assert_eq!(
992 index.diagnostics()[0].kind,
993 SeriesDiagnosticKind::MissingOccurrenceKey
994 );
995 }
996
997 #[test]
998 fn two_issues_cannot_share_one_occurrence_key() {
999 let issues = vec![
1000 occurrence(2, "a.md", "s-abc", "s-abc@2026-01-01", "2026-01-01"),
1001 occurrence(3, "b.md", "s-abc", "s-abc@2026-01-01", "2026-01-01"),
1002 ];
1003 let index = SeriesIndex::build(&issues);
1004 assert_eq!(index.occurrences().count(), 1);
1005 assert_eq!(
1006 index.diagnostics()[0].kind,
1007 SeriesDiagnosticKind::DuplicateOccurrenceKey
1008 );
1009 }
1010
1011 #[test]
1012 fn a_definition_that_is_its_own_occurrence_is_the_model_s_only_cycle() {
1013 let mut own = definition(1, "nightly.md", "s-abc", "");
1014 if let Some(reference) = own.series.as_mut() {
1015 reference.key = Some("s-abc@2026-01-01".to_string());
1016 reference.date = Some("2026-01-01".to_string());
1017 }
1018 let index = SeriesIndex::build(&[own]);
1019 assert!(
1020 index
1021 .diagnostics()
1022 .iter()
1023 .any(|d| d.kind == SeriesDiagnosticKind::SeriesIsItsOwnOccurrence),
1024 "{:?}",
1025 index.diagnostics()
1026 );
1027 }
1028
1029 #[test]
1030 fn a_skipped_date_is_taken_and_never_free() {
1031 let series = definition(1, "nightly.md", "s-abc", " skipped:\n - 2026-01-02\n");
1032 let index = SeriesIndex::build(&[series]);
1033 let skipped = key("s-abc@2026-01-02");
1034 assert_eq!(index.key_state(&skipped), KeyState::Skipped);
1035 assert!(index.is_taken(&skipped), "a skipped key is not free");
1036 assert!(!index.is_taken(&key("s-abc@2026-01-03")));
1037 }
1038
1039 #[test]
1040 fn only_active_allows_generation() {
1041 let ended = definition(1, "nightly.md", "s-abc", " state: ended\n");
1042 let index = SeriesIndex::build(&[ended]);
1043 let stored = index
1044 .series_by_id(&SeriesId::parse("s-abc").unwrap())
1045 .unwrap();
1046 assert_eq!(stored.state, SeriesLifecycle::Ended);
1047 assert!(!stored.allows_generation());
1048 assert!(!SeriesLifecycle::Paused.allows_generation());
1049 assert!(SeriesLifecycle::Active.allows_generation());
1050 }
1051
1052 #[test]
1053 fn identity_is_the_key_not_the_issue_number_or_the_title() {
1054 let first = occurrence(7, "a.md", "s-abc", "s-abc@2026-01-01", "2026-01-01");
1057 let mut renamed = occurrence(99, "b.md", "s-abc", "s-abc@2026-01-01", "2026-01-01");
1058 renamed.title = "Renamed".to_string();
1059 let index = SeriesIndex::build(&[first, renamed]);
1060 assert_eq!(
1061 index.diagnostics()[0].kind,
1062 SeriesDiagnosticKind::DuplicateOccurrenceKey
1063 );
1064 let key = key("s-abc@2026-01-01");
1065 assert_eq!(index.occurrence(&key).unwrap().issue_number, 7);
1066 }
1067
1068 #[test]
1069 fn a_definition_without_a_name_or_with_a_broken_rule_is_reported() {
1070 let unnamed = issue(
1071 1,
1072 "nightly.md",
1073 "series:\n id: s-abc\n rule:\n kind: daily\n anchor: 2026-01-01\n",
1074 );
1075 let index = SeriesIndex::build(&[unnamed]);
1076 assert_eq!(
1077 index.diagnostics()[0].kind,
1078 SeriesDiagnosticKind::MissingField
1079 );
1080 assert_eq!(index.diagnostics()[0].field, "series.name");
1081
1082 let bad_rule = issue(
1083 2,
1084 "other.md",
1085 "series:\n id: s-xyz\n name: Broken\n rule:\n kind: interval\n \
1086 anchor: 2026-01-01\n interval: 0\n unit: days\n",
1087 );
1088 let index = SeriesIndex::build(&[bad_rule]);
1089 assert!(
1090 index
1091 .diagnostics()
1092 .iter()
1093 .any(|d| d.kind == SeriesDiagnosticKind::InvalidRule),
1094 "{:?}",
1095 index.diagnostics()
1096 );
1097 }
1098
1099 #[test]
1100 fn a_finding_renders_through_the_shared_renderer() {
1101 let rendered = SeriesDiagnostic::new(
1102 SeriesDiagnosticKind::InvalidSeriesId,
1103 "series.id",
1104 "a series id cannot be empty",
1105 )
1106 .render();
1107 assert!(
1108 rendered.contains("error[E-tix-series]") && rendered.contains("invalid_series_id"),
1109 "{rendered}"
1110 );
1111 }
1112}