use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use serde::{Deserialize, Serialize};
use time::{Date, Month};
use crate::error::render_diagnostics;
use crate::model::{Issue, SeriesRef, SeriesRule};
use crate::recurrence::rule::{DateKey, IntervalUnit, RecurrenceRule, RuleKind, Timezone};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SeriesRole {
Definition,
Occurrence,
}
impl SeriesRef {
pub fn role(&self) -> SeriesRole {
if self.rule.is_some() {
SeriesRole::Definition
} else {
SeriesRole::Occurrence
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SeriesLifecycle {
#[default]
Active,
Paused,
Ended,
}
impl SeriesLifecycle {
pub const fn as_str(self) -> &'static str {
match self {
Self::Active => "active",
Self::Paused => "paused",
Self::Ended => "ended",
}
}
pub const fn allows_generation(self) -> bool {
matches!(self, Self::Active)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SeriesId(String);
impl SeriesId {
pub fn parse(raw: &str) -> Result<Self, SeriesError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(SeriesError::single(SeriesDiagnostic::new(
SeriesDiagnosticKind::InvalidSeriesId,
"series.id",
"a series id cannot be empty",
)));
}
if !trimmed
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
{
return Err(SeriesError::single(
SeriesDiagnostic::new(
SeriesDiagnosticKind::InvalidSeriesId,
"series.id",
"a series id may hold only ASCII letters, digits, `-`, `_` and `.`",
)
.with_value(raw),
));
}
Ok(Self(trimmed.to_string()))
}
pub fn generate() -> Self {
Self(format!("s-{}", crate::id::generate_issue_id()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for SeriesId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct OccurrenceKey(String);
impl OccurrenceKey {
pub fn new(series: &SeriesId, date: &DateKey) -> Self {
Self(format!("{}@{}", series.as_str(), date.as_str()))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn parse(raw: &str) -> Result<(SeriesId, DateKey), SeriesError> {
let Some((id, date)) = raw.split_once('@') else {
return Err(SeriesError::single(
SeriesDiagnostic::new(
SeriesDiagnosticKind::MalformedKey,
"series.key",
"a key is `<series-id>@<YYYY-MM-DD>`",
)
.with_value(raw),
));
};
let id = SeriesId::parse(id)?;
let date = parse_date_key(date).ok_or_else(|| {
SeriesError::single(
SeriesDiagnostic::new(
SeriesDiagnosticKind::MalformedKey,
"series.key",
"the date half must be a real `YYYY-MM-DD` calendar date",
)
.with_value(raw),
)
})?;
Ok((id, date))
}
}
impl fmt::Display for OccurrenceKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Series {
pub id: SeriesId,
pub name: String,
pub rule: RecurrenceRule,
pub state: SeriesLifecycle,
pub next_cursor: Option<DateKey>,
pub skipped: BTreeSet<DateKey>,
pub issue_number: u32,
pub filename: String,
}
impl Series {
pub fn from_ref(
reference: &SeriesRef,
issue_number: u32,
filename: &str,
) -> Result<Option<Self>, SeriesError> {
if reference.role() != SeriesRole::Definition {
return Ok(None);
}
let mut diagnostics: Vec<SeriesDiagnostic> = Vec::new();
let id = match SeriesId::parse(&reference.id) {
Ok(id) => Some(id),
Err(error) => {
diagnostics.extend(error.diagnostics().iter().cloned());
None
}
};
let name = reference.name.clone().unwrap_or_default();
if name.trim().is_empty() {
diagnostics.push(SeriesDiagnostic::new(
SeriesDiagnosticKind::MissingField,
"series.name",
"a series definition needs a display name (`series.name`)",
));
}
let stored = reference
.rule
.as_ref()
.expect("`role()` says a rule is set");
let rule = match typed_rule(stored) {
Ok(rule) => Some(rule),
Err(error) => {
diagnostics.extend(error.diagnostics().iter().cloned());
None
}
};
if let Some(rule) = rule.as_ref()
&& let Err(error) = rule.validate()
{
diagnostics.extend(error.diagnostics().iter().map(|diagnostic| {
SeriesDiagnostic::new(
SeriesDiagnosticKind::InvalidRule,
format!("series.rule.{}", diagnostic.field),
diagnostic.message.clone(),
)
}));
}
if !diagnostics.is_empty() {
return Err(SeriesError::new(diagnostics));
}
Ok(Some(Self {
id: id.expect("an unusable id is a diagnostic"),
name,
rule: rule.expect("a rule that does not build is a diagnostic"),
state: reference.state.unwrap_or_default(),
next_cursor: reference.next_cursor.as_deref().and_then(parse_date_key),
skipped: reference
.skipped
.iter()
.filter_map(|raw| parse_date_key(raw))
.collect(),
issue_number,
filename: filename.to_string(),
}))
}
pub const fn allows_generation(&self) -> bool {
self.state.allows_generation()
}
pub fn key_for(&self, date: &DateKey) -> OccurrenceKey {
OccurrenceKey::new(&self.id, date)
}
pub fn is_skipped(&self, date: &DateKey) -> bool {
self.skipped.contains(date)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct OccurrenceRecord {
pub series_id: SeriesId,
pub date: DateKey,
pub key: OccurrenceKey,
pub issue_number: u32,
pub filename: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum KeyState {
Generated,
Skipped,
Free,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SeriesDiagnosticKind {
InvalidSeriesId,
DuplicateSeriesId,
MissingField,
InvalidRule,
MalformedKey,
KeySeriesMismatch,
KeyDateMismatch,
MissingOccurrenceKey,
DuplicateOccurrenceKey,
SeriesIsItsOwnOccurrence,
}
impl SeriesDiagnosticKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::InvalidSeriesId => "invalid_series_id",
Self::DuplicateSeriesId => "duplicate_series_id",
Self::MissingField => "missing_series_field",
Self::InvalidRule => "invalid_series_rule",
Self::MalformedKey => "malformed_occurrence_key",
Self::KeySeriesMismatch => "occurrence_key_series_mismatch",
Self::KeyDateMismatch => "occurrence_key_date_mismatch",
Self::MissingOccurrenceKey => "missing_occurrence_key",
Self::DuplicateOccurrenceKey => "duplicate_occurrence_key",
Self::SeriesIsItsOwnOccurrence => "series_is_its_own_occurrence",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SeriesDiagnostic {
pub kind: SeriesDiagnosticKind,
pub issue_number: u32,
pub filename: String,
pub field: String,
pub message: String,
}
impl SeriesDiagnostic {
pub fn new(
kind: SeriesDiagnosticKind,
field: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
kind,
issue_number: 0,
filename: String::new(),
field: field.into(),
message: message.into(),
}
}
pub fn with_value(mut self, raw: &str) -> Self {
self.message = format!("{} (got `{raw}`)", self.message);
self
}
pub fn on(mut self, issue_number: u32, filename: &str) -> Self {
self.issue_number = issue_number;
self.filename = filename.to_string();
self
}
pub fn render(&self) -> String {
render_diagnostics(
"E-tix-series",
std::iter::once((self.kind.as_str(), self.message.as_str())),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SeriesError {
diagnostics: Vec<SeriesDiagnostic>,
}
impl SeriesError {
pub fn new(diagnostics: Vec<SeriesDiagnostic>) -> Self {
Self { diagnostics }
}
pub fn single(diagnostic: SeriesDiagnostic) -> Self {
Self {
diagnostics: vec![diagnostic],
}
}
pub fn diagnostics(&self) -> &[SeriesDiagnostic] {
&self.diagnostics
}
pub fn first(&self) -> &SeriesDiagnostic {
&self.diagnostics[0]
}
}
impl fmt::Display for SeriesError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.first().message)
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SeriesIndex {
series: BTreeMap<SeriesId, Series>,
occurrences: BTreeMap<OccurrenceKey, OccurrenceRecord>,
diagnostics: Vec<SeriesDiagnostic>,
}
impl SeriesIndex {
pub fn build(issues: &[Issue]) -> Self {
let mut index = Self::default();
for issue in issues {
if let Some(reference) = issue.series.as_ref() {
index.absorb(issue, reference);
}
}
index.check_cycles(issues);
index
}
fn absorb(&mut self, issue: &Issue, reference: &SeriesRef) {
match reference.role() {
SeriesRole::Definition => self.absorb_definition(issue, reference),
SeriesRole::Occurrence => self.absorb_occurrence(issue, reference),
}
}
fn absorb_definition(&mut self, issue: &Issue, reference: &SeriesRef) {
match Series::from_ref(reference, issue.number, &issue.filename) {
Ok(Some(series)) => {
if let Some(previous) = self.series.get(&series.id) {
self.diagnostics.push(
SeriesDiagnostic::new(
SeriesDiagnosticKind::DuplicateSeriesId,
"series.id",
format!(
"series `{}` is already defined by {}; two issues cannot share \
one series id",
series.id, previous.filename
),
)
.on(issue.number, &issue.filename),
);
return;
}
self.series.insert(series.id.clone(), series);
}
Ok(None) => {}
Err(error) => self.diagnostics.extend(
error
.diagnostics()
.iter()
.cloned()
.map(|diagnostic| diagnostic.on(issue.number, &issue.filename)),
),
}
}
fn absorb_occurrence(&mut self, issue: &Issue, reference: &SeriesRef) {
let Some(raw_key) = reference.key.as_deref() else {
self.diagnostics.push(
SeriesDiagnostic::new(
SeriesDiagnosticKind::MissingOccurrenceKey,
"series.key",
"an occurrence needs the key it was generated for \
(`<series-id>@<YYYY-MM-DD>`)",
)
.on(issue.number, &issue.filename),
);
return;
};
let (series_id, date) = match OccurrenceKey::parse(raw_key) {
Ok(parts) => parts,
Err(error) => {
self.diagnostics.extend(
error
.diagnostics()
.iter()
.cloned()
.map(|diagnostic| diagnostic.on(issue.number, &issue.filename)),
);
return;
}
};
if series_id.as_str() != reference.id {
self.diagnostics.push(
SeriesDiagnostic::new(
SeriesDiagnosticKind::KeySeriesMismatch,
"series.key",
format!(
"the key names series `{series_id}` but `series.id` is `{}`",
reference.id
),
)
.on(issue.number, &issue.filename),
);
}
if let Some(declared) = reference.date.as_deref()
&& declared != date.as_str()
{
self.diagnostics.push(
SeriesDiagnostic::new(
SeriesDiagnosticKind::KeyDateMismatch,
"series.date",
format!("`series.date` is `{declared}` but the key names `{date}`"),
)
.on(issue.number, &issue.filename),
);
}
let key = OccurrenceKey::new(&series_id, &date);
if let Some(previous) = self.occurrences.get(&key) {
self.diagnostics.push(
SeriesDiagnostic::new(
SeriesDiagnosticKind::DuplicateOccurrenceKey,
"series.key",
format!(
"occurrence `{key}` already belongs to {}; one key is one occurrence",
previous.filename
),
)
.on(issue.number, &issue.filename),
);
return;
}
self.occurrences.insert(
key.clone(),
OccurrenceRecord {
series_id,
date,
key,
issue_number: issue.number,
filename: issue.filename.clone(),
},
);
}
fn check_cycles(&mut self, issues: &[Issue]) {
for issue in issues {
let Some(reference) = issue.series.as_ref() else {
continue;
};
if reference.role() != SeriesRole::Definition {
continue;
}
let Some(raw_key) = reference.key.as_deref() else {
continue;
};
self.diagnostics.push(
SeriesDiagnostic::new(
SeriesDiagnosticKind::SeriesIsItsOwnOccurrence,
"series.key",
format!(
"issue {} defines series `{}` and also carries the occurrence key \
`{raw_key}`; a definition cannot be an occurrence of the series it \
would generate",
issue.number, reference.id
),
)
.on(issue.number, &issue.filename),
);
}
}
pub fn series(&self) -> impl Iterator<Item = &Series> {
self.series.values()
}
pub fn series_by_id(&self, id: &SeriesId) -> Option<&Series> {
self.series.get(id)
}
pub fn series_count(&self) -> usize {
self.series.len()
}
pub fn occurrence(&self, key: &OccurrenceKey) -> Option<&OccurrenceRecord> {
self.occurrences.get(key)
}
pub fn occurrences(&self) -> impl Iterator<Item = &OccurrenceRecord> {
self.occurrences.values()
}
pub fn key_state(&self, key: &OccurrenceKey) -> KeyState {
if self.occurrences.contains_key(key) {
return KeyState::Generated;
}
if let Ok((id, date)) = OccurrenceKey::parse(key.as_str())
&& self
.series
.get(&id)
.is_some_and(|series| series.is_skipped(&date))
{
return KeyState::Skipped;
}
KeyState::Free
}
pub fn is_taken(&self, key: &OccurrenceKey) -> bool {
self.key_state(key) != KeyState::Free
}
pub fn diagnostics(&self) -> &[SeriesDiagnostic] {
&self.diagnostics
}
pub fn is_consistent(&self) -> bool {
self.diagnostics.is_empty()
}
}
fn typed_rule(stored: &SeriesRule) -> Result<RecurrenceRule, SeriesError> {
let mut diagnostics = Vec::new();
let kind = match stored.kind.as_deref().map(str::trim) {
Some(raw) => match parse_kind(raw) {
Some(kind) => Some(kind),
None => {
diagnostics.push(
SeriesDiagnostic::new(
SeriesDiagnosticKind::InvalidRule,
"series.rule.kind",
"the rule kind must be one of `daily`, `weekly`, `monthly`, `interval`, `cron-like`",
)
.with_value(raw),
);
None
}
},
None => {
diagnostics.push(SeriesDiagnostic::new(
SeriesDiagnosticKind::InvalidRule,
"series.rule.kind",
"a series rule needs a kind (`series.rule.kind`)",
));
None
}
};
let unit = match stored.unit.as_deref().map(str::trim) {
Some(raw) => match parse_unit(raw) {
Some(unit) => Some(unit),
None => {
diagnostics.push(
SeriesDiagnostic::new(
SeriesDiagnosticKind::InvalidRule,
"series.rule.unit",
"the unit must be one of `days`, `weeks`, `months`",
)
.with_value(raw),
);
None
}
},
None => None,
};
if !diagnostics.is_empty() {
return Err(SeriesError::new(diagnostics));
}
Ok(RecurrenceRule {
kind: kind.expect("a missing kind is a diagnostic"),
interval: stored.interval,
anchor: stored.anchor.clone(),
timezone: stored.timezone.as_deref().map(Timezone::parse),
expression: stored.expression.clone(),
unit,
extensions: stored
.extensions
.iter()
.map(|(key, value)| (key.clone(), serde_yaml::Value::String(value.clone())))
.collect(),
})
}
fn parse_kind(raw: &str) -> Option<RuleKind> {
match raw {
"daily" => Some(RuleKind::Daily),
"weekly" => Some(RuleKind::Weekly),
"monthly" => Some(RuleKind::Monthly),
"interval" => Some(RuleKind::Interval),
"cron-like" => Some(RuleKind::CronLike),
_ => None,
}
}
fn parse_unit(raw: &str) -> Option<IntervalUnit> {
match raw {
"days" => Some(IntervalUnit::Days),
"weeks" => Some(IntervalUnit::Weeks),
"months" => Some(IntervalUnit::Months),
_ => None,
}
}
fn parse_date_key(raw: &str) -> Option<DateKey> {
if !crate::model::validate_date(raw) {
return None;
}
let year: i32 = raw.get(0..4)?.parse().ok()?;
let month: u8 = raw.get(5..7)?.parse().ok()?;
let day: u8 = raw.get(8..10)?.parse().ok()?;
let date = Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()?;
Some(DateKey::from_date(date))
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
fn issue(number: u32, filename: &str, series: &str) -> Issue {
let contents = format!(
"---\nnumber: {number}\ntitle: \"Fixture {number}\"\nstatus: \"todo\"\n\
created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n{series}---\n\nBody.\n"
);
let mut issue = Issue::parse(&contents).expect("the fixture parses");
issue.filename = filename.to_string();
issue
}
fn date(raw: &str) -> DateKey {
parse_date_key(raw).expect("a real date")
}
fn key(raw: &str) -> OccurrenceKey {
let (id, date) = OccurrenceKey::parse(raw).expect("a well-formed key");
OccurrenceKey::new(&id, &date)
}
fn definition(number: u32, filename: &str, id: &str, extra: &str) -> Issue {
issue(
number,
filename,
&format!(
"series:\n id: {id}\n name: Nightly\n rule:\n kind: daily\n \
anchor: 2026-01-01\n{extra}"
),
)
}
fn occurrence(number: u32, filename: &str, id: &str, key: &str, date: &str) -> Issue {
issue(
number,
filename,
&format!("series:\n id: {id}\n date: {date}\n key: {key}\n"),
)
}
#[test]
fn a_key_is_the_series_and_the_date() {
let id = SeriesId::parse("s-abc").unwrap();
let key = OccurrenceKey::new(&id, &date("2026-02-28"));
assert_eq!(key.as_str(), "s-abc@2026-02-28");
let (parsed_id, parsed_date) = OccurrenceKey::parse(key.as_str()).unwrap();
assert_eq!(parsed_id, id);
assert_eq!(parsed_date, date("2026-02-28"));
}
#[test]
fn a_malformed_key_names_what_a_key_is() {
assert_eq!(
OccurrenceKey::parse("no-separator")
.unwrap_err()
.first()
.kind,
SeriesDiagnosticKind::MalformedKey
);
assert_eq!(
OccurrenceKey::parse("s-abc@2026-02-30")
.unwrap_err()
.first()
.kind,
SeriesDiagnosticKind::MalformedKey
);
}
#[test]
fn an_unsafe_id_is_refused() {
assert_eq!(
SeriesId::parse("").unwrap_err().first().kind,
SeriesDiagnosticKind::InvalidSeriesId
);
assert_eq!(
SeriesId::parse("s abc").unwrap_err().first().kind,
SeriesDiagnosticKind::InvalidSeriesId
);
assert_eq!(SeriesId::parse("s-abc_1.2").unwrap().as_str(), "s-abc_1.2");
assert!(SeriesId::generate().as_str().starts_with("s-"));
}
#[test]
fn the_index_separates_definitions_from_occurrences() {
let issues = vec![
definition(1, "nightly.md", "s-abc", ""),
occurrence(2, "a.md", "s-abc", "s-abc@2026-01-01", "2026-01-01"),
];
let index = SeriesIndex::build(&issues);
assert!(index.is_consistent(), "{:?}", index.diagnostics());
assert_eq!(index.series_count(), 1);
assert_eq!(index.occurrences().count(), 1);
assert_eq!(
index.key_state(&key("s-abc@2026-01-01")),
KeyState::Generated
);
assert_eq!(index.key_state(&key("s-abc@2026-01-02")), KeyState::Free);
}
#[test]
fn a_duplicate_series_id_is_reported_and_the_first_definition_wins() {
let issues = vec![
definition(1, "first.md", "s-abc", ""),
definition(2, "second.md", "s-abc", ""),
];
let index = SeriesIndex::build(&issues);
assert_eq!(index.series_count(), 1);
assert_eq!(
index.diagnostics()[0].kind,
SeriesDiagnosticKind::DuplicateSeriesId
);
assert_eq!(
index
.series_by_id(&SeriesId::parse("s-abc").unwrap())
.unwrap()
.issue_number,
1
);
}
#[test]
fn a_key_that_contradicts_its_declared_series_or_date_is_reported() {
let issues = vec![
definition(1, "nightly.md", "s-abc", ""),
occurrence(2, "a.md", "s-abc", "s-xyz@2026-01-01", "2026-01-01"),
occurrence(3, "b.md", "s-abc", "s-abc@2026-01-02", "2026-01-03"),
];
let index = SeriesIndex::build(&issues);
let kinds: Vec<_> = index.diagnostics().iter().map(|d| d.kind).collect();
assert!(
kinds.contains(&SeriesDiagnosticKind::KeySeriesMismatch),
"{kinds:?}"
);
assert!(
kinds.contains(&SeriesDiagnosticKind::KeyDateMismatch),
"{kinds:?}"
);
}
#[test]
fn an_occurrence_without_a_key_is_reported_rather_than_guessed() {
let broken = issue(2, "a.md", "series:\n id: s-abc\n date: 2026-01-01\n");
let index = SeriesIndex::build(&[broken]);
assert_eq!(
index.diagnostics()[0].kind,
SeriesDiagnosticKind::MissingOccurrenceKey
);
}
#[test]
fn two_issues_cannot_share_one_occurrence_key() {
let issues = vec![
occurrence(2, "a.md", "s-abc", "s-abc@2026-01-01", "2026-01-01"),
occurrence(3, "b.md", "s-abc", "s-abc@2026-01-01", "2026-01-01"),
];
let index = SeriesIndex::build(&issues);
assert_eq!(index.occurrences().count(), 1);
assert_eq!(
index.diagnostics()[0].kind,
SeriesDiagnosticKind::DuplicateOccurrenceKey
);
}
#[test]
fn a_definition_that_is_its_own_occurrence_is_the_model_s_only_cycle() {
let mut own = definition(1, "nightly.md", "s-abc", "");
if let Some(reference) = own.series.as_mut() {
reference.key = Some("s-abc@2026-01-01".to_string());
reference.date = Some("2026-01-01".to_string());
}
let index = SeriesIndex::build(&[own]);
assert!(
index
.diagnostics()
.iter()
.any(|d| d.kind == SeriesDiagnosticKind::SeriesIsItsOwnOccurrence),
"{:?}",
index.diagnostics()
);
}
#[test]
fn a_skipped_date_is_taken_and_never_free() {
let series = definition(1, "nightly.md", "s-abc", " skipped:\n - 2026-01-02\n");
let index = SeriesIndex::build(&[series]);
let skipped = key("s-abc@2026-01-02");
assert_eq!(index.key_state(&skipped), KeyState::Skipped);
assert!(index.is_taken(&skipped), "a skipped key is not free");
assert!(!index.is_taken(&key("s-abc@2026-01-03")));
}
#[test]
fn only_active_allows_generation() {
let ended = definition(1, "nightly.md", "s-abc", " state: ended\n");
let index = SeriesIndex::build(&[ended]);
let stored = index
.series_by_id(&SeriesId::parse("s-abc").unwrap())
.unwrap();
assert_eq!(stored.state, SeriesLifecycle::Ended);
assert!(!stored.allows_generation());
assert!(!SeriesLifecycle::Paused.allows_generation());
assert!(SeriesLifecycle::Active.allows_generation());
}
#[test]
fn identity_is_the_key_not_the_issue_number_or_the_title() {
let first = occurrence(7, "a.md", "s-abc", "s-abc@2026-01-01", "2026-01-01");
let mut renamed = occurrence(99, "b.md", "s-abc", "s-abc@2026-01-01", "2026-01-01");
renamed.title = "Renamed".to_string();
let index = SeriesIndex::build(&[first, renamed]);
assert_eq!(
index.diagnostics()[0].kind,
SeriesDiagnosticKind::DuplicateOccurrenceKey
);
let key = key("s-abc@2026-01-01");
assert_eq!(index.occurrence(&key).unwrap().issue_number, 7);
}
#[test]
fn a_definition_without_a_name_or_with_a_broken_rule_is_reported() {
let unnamed = issue(
1,
"nightly.md",
"series:\n id: s-abc\n rule:\n kind: daily\n anchor: 2026-01-01\n",
);
let index = SeriesIndex::build(&[unnamed]);
assert_eq!(
index.diagnostics()[0].kind,
SeriesDiagnosticKind::MissingField
);
assert_eq!(index.diagnostics()[0].field, "series.name");
let bad_rule = issue(
2,
"other.md",
"series:\n id: s-xyz\n name: Broken\n rule:\n kind: interval\n \
anchor: 2026-01-01\n interval: 0\n unit: days\n",
);
let index = SeriesIndex::build(&[bad_rule]);
assert!(
index
.diagnostics()
.iter()
.any(|d| d.kind == SeriesDiagnosticKind::InvalidRule),
"{:?}",
index.diagnostics()
);
}
#[test]
fn a_finding_renders_through_the_shared_renderer() {
let rendered = SeriesDiagnostic::new(
SeriesDiagnosticKind::InvalidSeriesId,
"series.id",
"a series id cannot be empty",
)
.render();
assert!(
rendered.contains("error[E-tix-series]") && rendered.contains("invalid_series_id"),
"{rendered}"
);
}
}