use crate::commands::create;
use crate::config::Config;
use crate::error::render_diagnostics;
use crate::model::{HistoryEventKind, SeriesRef};
use crate::recurrence::rule::DateKey;
use crate::recurrence::series::{KeyState, OccurrenceKey, SeriesId, SeriesIndex};
use crate::relationships::RelationKind;
use crate::relationships::Relationship;
use crate::storage::Store;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GenerationRequest {
pub series_id: SeriesId,
pub occurrence_key: OccurrenceKey,
pub actor: String,
pub confirmed: bool,
}
impl GenerationRequest {
pub fn new(
series_id: SeriesId,
occurrence_key: OccurrenceKey,
actor: impl Into<String>,
) -> Self {
Self {
series_id,
occurrence_key,
actor: actor.into(),
confirmed: false,
}
}
pub fn confirm(mut self) -> Self {
self.confirmed = true;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GenerationDiagnosticKind {
NotConfirmed,
MalformedKey,
KeySeriesMismatch,
UnknownSeries,
SeriesNotActive,
DefinitionUnreadable,
Refused,
}
impl GenerationDiagnosticKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::NotConfirmed => "not_confirmed",
Self::MalformedKey => "malformed_occurrence_key",
Self::KeySeriesMismatch => "occurrence_key_series_mismatch",
Self::UnknownSeries => "unknown_series",
Self::SeriesNotActive => "series_not_active",
Self::DefinitionUnreadable => "definition_unreadable",
Self::Refused => "refused",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GenerationDiagnostic {
pub kind: GenerationDiagnosticKind,
pub message: String,
}
impl GenerationDiagnostic {
fn new(kind: GenerationDiagnosticKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
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 enum GenerationOutcome {
Created {
series_id: SeriesId,
key: OccurrenceKey,
date: DateKey,
issue_number: u32,
filename: String,
},
NoOp {
series_id: SeriesId,
key: OccurrenceKey,
state: KeyState,
message: String,
},
Rejected {
diagnostics: Vec<GenerationDiagnostic>,
},
}
impl GenerationOutcome {
pub fn is_created(&self) -> bool {
matches!(self, Self::Created { .. })
}
pub fn is_noop(&self) -> bool {
matches!(self, Self::NoOp { .. })
}
pub fn is_rejected(&self) -> bool {
matches!(self, Self::Rejected { .. })
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Created { .. } => "created",
Self::NoOp { .. } => "no-op",
Self::Rejected { .. } => "rejected",
}
}
pub fn render(&self) -> String {
match self {
Self::Created {
key,
issue_number,
filename,
..
} => format!("created #{issue_number} for `{key}` ({filename})"),
Self::NoOp { key, message, .. } => format!("no-op for `{key}`: {message}"),
Self::Rejected { diagnostics } => {
let first = diagnostics
.first()
.map(GenerationDiagnostic::render)
.unwrap_or_else(|| "rejected".to_string());
if diagnostics.len() > 1 {
format!("{first} (and {} more)", diagnostics.len() - 1)
} else {
first
}
}
}
}
}
pub fn generate(
config: &Config,
store: &Store,
index: &SeriesIndex,
request: &GenerationRequest,
) -> crate::error::Result<GenerationOutcome> {
if !request.confirmed {
return Ok(rejected(GenerationDiagnostic::new(
GenerationDiagnosticKind::NotConfirmed,
"generation must be confirmed: detection reports, a person or a caller \
decides",
)));
}
let (key_series, date) = match OccurrenceKey::parse(request.occurrence_key.as_str()) {
Ok(parts) => parts,
Err(error) => {
return Ok(rejected(GenerationDiagnostic::new(
GenerationDiagnosticKind::MalformedKey,
error.first().message.clone(),
)));
}
};
if key_series != request.series_id {
return Ok(rejected(GenerationDiagnostic::new(
GenerationDiagnosticKind::KeySeriesMismatch,
format!(
"key `{}` names series `{key_series}` but the request names `{}`",
request.occurrence_key, request.series_id
),
)));
}
let Some(series) = index.series_by_id(&request.series_id) else {
return Ok(rejected(GenerationDiagnostic::new(
GenerationDiagnosticKind::UnknownSeries,
format!("no series `{}` is defined", request.series_id),
)));
};
match index.key_state(&request.occurrence_key) {
KeyState::Generated => {
return Ok(GenerationOutcome::NoOp {
series_id: series.id.clone(),
key: request.occurrence_key.clone(),
state: KeyState::Generated,
message: format!(
"occurrence `{}` already exists ({})",
request.occurrence_key,
index
.occurrence(&request.occurrence_key)
.map(|record| record.filename.clone())
.unwrap_or_else(|| "in the index".to_string())
),
});
}
KeyState::Skipped => {
return Ok(GenerationOutcome::NoOp {
series_id: series.id.clone(),
key: request.occurrence_key.clone(),
state: KeyState::Skipped,
message: "the series recorded this date as skipped, and a skipped \
key is never regenerated"
.to_string(),
});
}
KeyState::Free => {}
}
if !series.allows_generation() {
return Ok(rejected(GenerationDiagnostic::new(
GenerationDiagnosticKind::SeriesNotActive,
format!(
"series `{}` is {}; resume or restart it before generating",
series.id,
series.state.as_str()
),
)));
}
let issues = store.list()?;
let Some(definition) = issues
.iter()
.find(|issue| issue.number == series.issue_number)
else {
return Ok(rejected(GenerationDiagnostic::new(
GenerationDiagnosticKind::DefinitionUnreadable,
format!(
"series `{}` is defined by issue #{} ({}) but that issue could not be read",
series.id, series.issue_number, series.filename
),
)));
};
let title = occurrence_title(&definition.title, &date);
let labels = definition.labels.join(",");
let files = definition.files.join(",");
let cycles = definition
.cycles
.as_ref()
.map(|cycles| cycles.join(","))
.unwrap_or_default();
let milestones = definition
.milestones
.as_ref()
.map(|milestones| milestones.join(","))
.unwrap_or_default();
let created = store.transaction(|| {
let mut occurrence = create::create(
config,
store,
&title,
Some(&definition.body),
Some(definition.status.clone()),
Some(definition.priority.clone()),
definition.assignee.as_deref(),
Some(labels.as_str()),
Some(files.as_str()),
definition.estimate.as_deref(),
Some(cycles.as_str()),
Some(milestones.as_str()),
definition.reviewer.as_deref(),
definition.start_date.as_deref(),
definition.due_date.as_deref(),
None,
None,
definition.type_.as_deref(),
)?;
occurrence.series = Some(SeriesRef {
id: series.id.as_str().to_string(),
name: Some(series.name.clone()),
date: Some(date.as_str().to_string()),
key: Some(request.occurrence_key.as_str().to_string()),
rule: None,
state: None,
next_cursor: None,
skipped: Vec::new(),
});
occurrence.relationships.push(Relationship::new(
occurrence.number,
definition.number,
RelationKind::Related,
&definition.title,
));
occurrence.history.append_now(
&request.actor,
HistoryEventKind::RelationshipAdded,
&format!(
"generated occurrence `{}` of series `{}` ({})",
request.occurrence_key, series.id, series.name
),
);
let filename = store.update(&occurrence.filename, &occurrence)?;
let mut definition = definition.clone();
if let Some(reference) = definition.series.as_mut() {
reference.next_cursor = Some(date.as_str().to_string());
}
store.update(&definition.filename, &definition)?;
Ok((occurrence, filename))
});
match created {
Ok((occurrence, filename)) => Ok(GenerationOutcome::Created {
series_id: series.id.clone(),
key: request.occurrence_key.clone(),
date,
issue_number: occurrence.number,
filename,
}),
Err(error) => Ok(rejected(GenerationDiagnostic::new(
GenerationDiagnosticKind::Refused,
format!("the create core refused the occurrence: {error}"),
))),
}
}
fn occurrence_title(series_title: &str, date: &DateKey) -> String {
format!("{series_title} ({date})")
}
fn rejected(diagnostic: GenerationDiagnostic) -> GenerationOutcome {
GenerationOutcome::Rejected {
diagnostics: vec![diagnostic],
}
}
#[cfg(test)]
mod tests {
use super::*;
fn date_and_key() -> (DateKey, OccurrenceKey) {
let date = DateKey::from_date(
time::Date::from_calendar_date(2026, time::Month::January, 1).expect("a real date"),
);
let id = SeriesId::parse("s-abc").expect("a valid id");
let key = OccurrenceKey::new(&id, &date);
(date, key)
}
#[test]
fn a_request_is_unconfirmed_until_it_says_so() {
let (_, key) = date_and_key();
let request =
GenerationRequest::new(SeriesId::parse("s-abc").unwrap(), key.clone(), "alice");
assert!(!request.confirmed, "reading two ids is not consent");
let confirmed = request.clone().confirm();
assert!(confirmed.confirmed);
assert_eq!(confirmed.actor, "alice");
assert_eq!(confirmed.occurrence_key, key);
}
#[test]
fn an_occurrence_title_names_the_date_after_the_series_title() {
let (date, _) = date_and_key();
assert_eq!(
occurrence_title("Nightly close", &date),
"Nightly close (2026-01-01)"
);
}
#[test]
fn every_outcome_has_one_stable_word() {
let (date, key) = date_and_key();
let series_id = SeriesId::parse("s-abc").unwrap();
let created = GenerationOutcome::Created {
series_id: series_id.clone(),
key: key.clone(),
date: date.clone(),
issue_number: 7,
filename: "a.md".to_string(),
};
assert_eq!(created.as_str(), "created");
assert!(created.is_created());
assert!(created.render().contains("#7"));
let noop = GenerationOutcome::NoOp {
series_id,
key,
state: KeyState::Generated,
message: "already exists".to_string(),
};
assert_eq!(noop.as_str(), "no-op");
assert!(noop.is_noop());
assert!(noop.render().contains("already exists"));
let rejected = GenerationOutcome::Rejected {
diagnostics: vec![
GenerationDiagnostic::new(
GenerationDiagnosticKind::NotConfirmed,
"generation must be confirmed",
),
GenerationDiagnostic::new(GenerationDiagnosticKind::UnknownSeries, "no series"),
],
};
assert_eq!(rejected.as_str(), "rejected");
assert!(rejected.is_rejected());
assert!(
rejected.render().contains("not_confirmed"),
"{}",
rejected.render()
);
assert!(
rejected.render().contains("and 1 more"),
"a second reason is counted, not hidden: {}",
rejected.render()
);
}
#[test]
fn the_diagnostic_kinds_are_the_closed_set() {
let spellings: Vec<&str> = [
GenerationDiagnosticKind::NotConfirmed,
GenerationDiagnosticKind::MalformedKey,
GenerationDiagnosticKind::KeySeriesMismatch,
GenerationDiagnosticKind::UnknownSeries,
GenerationDiagnosticKind::SeriesNotActive,
GenerationDiagnosticKind::DefinitionUnreadable,
GenerationDiagnosticKind::Refused,
]
.iter()
.map(|kind| kind.as_str())
.collect();
assert_eq!(
spellings,
vec![
"not_confirmed",
"malformed_occurrence_key",
"occurrence_key_series_mismatch",
"unknown_series",
"series_not_active",
"definition_unreadable",
"refused",
]
);
}
}