1use crate::commands::create;
46use crate::config::Config;
47use crate::error::render_diagnostics;
48use crate::model::{HistoryEventKind, SeriesRef};
49use crate::recurrence::rule::DateKey;
50use crate::recurrence::series::{KeyState, OccurrenceKey, SeriesId, SeriesIndex};
51use crate::relationships::RelationKind;
52use crate::relationships::Relationship;
53use crate::storage::Store;
54
55#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct GenerationRequest {
62 pub series_id: SeriesId,
64 pub occurrence_key: OccurrenceKey,
66 pub actor: String,
68 pub confirmed: bool,
70}
71
72impl GenerationRequest {
73 pub fn new(
76 series_id: SeriesId,
77 occurrence_key: OccurrenceKey,
78 actor: impl Into<String>,
79 ) -> Self {
80 Self {
81 series_id,
82 occurrence_key,
83 actor: actor.into(),
84 confirmed: false,
85 }
86 }
87
88 pub fn confirm(mut self) -> Self {
90 self.confirmed = true;
91 self
92 }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
97pub enum GenerationDiagnosticKind {
98 NotConfirmed,
100 MalformedKey,
102 KeySeriesMismatch,
104 UnknownSeries,
106 SeriesNotActive,
108 DefinitionUnreadable,
110 Refused,
112}
113
114impl GenerationDiagnosticKind {
115 pub const fn as_str(self) -> &'static str {
117 match self {
118 Self::NotConfirmed => "not_confirmed",
119 Self::MalformedKey => "malformed_occurrence_key",
120 Self::KeySeriesMismatch => "occurrence_key_series_mismatch",
121 Self::UnknownSeries => "unknown_series",
122 Self::SeriesNotActive => "series_not_active",
123 Self::DefinitionUnreadable => "definition_unreadable",
124 Self::Refused => "refused",
125 }
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct GenerationDiagnostic {
132 pub kind: GenerationDiagnosticKind,
134 pub message: String,
136}
137
138impl GenerationDiagnostic {
139 fn new(kind: GenerationDiagnosticKind, message: impl Into<String>) -> Self {
140 Self {
141 kind,
142 message: message.into(),
143 }
144 }
145
146 pub fn render(&self) -> String {
149 render_diagnostics(
150 "E-tix-series",
151 std::iter::once((self.kind.as_str(), self.message.as_str())),
152 )
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum GenerationOutcome {
163 Created {
165 series_id: SeriesId,
167 key: OccurrenceKey,
169 date: DateKey,
171 issue_number: u32,
173 filename: String,
175 },
176 NoOp {
179 series_id: SeriesId,
181 key: OccurrenceKey,
183 state: KeyState,
185 message: String,
187 },
188 Rejected {
190 diagnostics: Vec<GenerationDiagnostic>,
192 },
193}
194
195impl GenerationOutcome {
196 pub fn is_created(&self) -> bool {
198 matches!(self, Self::Created { .. })
199 }
200
201 pub fn is_noop(&self) -> bool {
203 matches!(self, Self::NoOp { .. })
204 }
205
206 pub fn is_rejected(&self) -> bool {
208 matches!(self, Self::Rejected { .. })
209 }
210
211 pub fn as_str(&self) -> &'static str {
213 match self {
214 Self::Created { .. } => "created",
215 Self::NoOp { .. } => "no-op",
216 Self::Rejected { .. } => "rejected",
217 }
218 }
219
220 pub fn render(&self) -> String {
222 match self {
223 Self::Created {
224 key,
225 issue_number,
226 filename,
227 ..
228 } => format!("created #{issue_number} for `{key}` ({filename})"),
229 Self::NoOp { key, message, .. } => format!("no-op for `{key}`: {message}"),
230 Self::Rejected { diagnostics } => {
231 let first = diagnostics
232 .first()
233 .map(GenerationDiagnostic::render)
234 .unwrap_or_else(|| "rejected".to_string());
235 if diagnostics.len() > 1 {
236 format!("{first} (and {} more)", diagnostics.len() - 1)
237 } else {
238 first
239 }
240 }
241 }
242 }
243}
244
245#[allow(clippy::too_many_lines)]
252pub fn generate(
253 config: &Config,
254 store: &Store,
255 index: &SeriesIndex,
256 request: &GenerationRequest,
257) -> crate::error::Result<GenerationOutcome> {
258 if !request.confirmed {
261 return Ok(rejected(GenerationDiagnostic::new(
262 GenerationDiagnosticKind::NotConfirmed,
263 "generation must be confirmed: detection reports, a person or a caller \
264 decides",
265 )));
266 }
267 let (key_series, date) = match OccurrenceKey::parse(request.occurrence_key.as_str()) {
268 Ok(parts) => parts,
269 Err(error) => {
270 return Ok(rejected(GenerationDiagnostic::new(
271 GenerationDiagnosticKind::MalformedKey,
272 error.first().message.clone(),
273 )));
274 }
275 };
276 if key_series != request.series_id {
277 return Ok(rejected(GenerationDiagnostic::new(
278 GenerationDiagnosticKind::KeySeriesMismatch,
279 format!(
280 "key `{}` names series `{key_series}` but the request names `{}`",
281 request.occurrence_key, request.series_id
282 ),
283 )));
284 }
285 let Some(series) = index.series_by_id(&request.series_id) else {
290 return Ok(rejected(GenerationDiagnostic::new(
291 GenerationDiagnosticKind::UnknownSeries,
292 format!("no series `{}` is defined", request.series_id),
293 )));
294 };
295 match index.key_state(&request.occurrence_key) {
296 KeyState::Generated => {
297 return Ok(GenerationOutcome::NoOp {
298 series_id: series.id.clone(),
299 key: request.occurrence_key.clone(),
300 state: KeyState::Generated,
301 message: format!(
302 "occurrence `{}` already exists ({})",
303 request.occurrence_key,
304 index
305 .occurrence(&request.occurrence_key)
306 .map(|record| record.filename.clone())
307 .unwrap_or_else(|| "in the index".to_string())
308 ),
309 });
310 }
311 KeyState::Skipped => {
312 return Ok(GenerationOutcome::NoOp {
313 series_id: series.id.clone(),
314 key: request.occurrence_key.clone(),
315 state: KeyState::Skipped,
316 message: "the series recorded this date as skipped, and a skipped \
317 key is never regenerated"
318 .to_string(),
319 });
320 }
321 KeyState::Free => {}
322 }
323 if !series.allows_generation() {
324 return Ok(rejected(GenerationDiagnostic::new(
325 GenerationDiagnosticKind::SeriesNotActive,
326 format!(
327 "series `{}` is {}; resume or restart it before generating",
328 series.id,
329 series.state.as_str()
330 ),
331 )));
332 }
333
334 let issues = store.list()?;
338 let Some(definition) = issues
339 .iter()
340 .find(|issue| issue.number == series.issue_number)
341 else {
342 return Ok(rejected(GenerationDiagnostic::new(
343 GenerationDiagnosticKind::DefinitionUnreadable,
344 format!(
345 "series `{}` is defined by issue #{} ({}) but that issue could not be read",
346 series.id, series.issue_number, series.filename
347 ),
348 )));
349 };
350
351 let title = occurrence_title(&definition.title, &date);
355 let labels = definition.labels.join(",");
356 let files = definition.files.join(",");
357 let cycles = definition
358 .cycles
359 .as_ref()
360 .map(|cycles| cycles.join(","))
361 .unwrap_or_default();
362 let milestones = definition
363 .milestones
364 .as_ref()
365 .map(|milestones| milestones.join(","))
366 .unwrap_or_default();
367 let created = store.transaction(|| {
368 let mut occurrence = create::create(
369 config,
370 store,
371 &title,
372 Some(&definition.body),
373 Some(definition.status.clone()),
374 Some(definition.priority.clone()),
375 definition.assignee.as_deref(),
376 Some(labels.as_str()),
377 Some(files.as_str()),
378 definition.estimate.as_deref(),
379 Some(cycles.as_str()),
380 Some(milestones.as_str()),
381 definition.reviewer.as_deref(),
382 definition.start_date.as_deref(),
383 definition.due_date.as_deref(),
384 None,
385 None,
386 definition.type_.as_deref(),
387 )?;
388 occurrence.series = Some(SeriesRef {
390 id: series.id.as_str().to_string(),
391 name: Some(series.name.clone()),
392 date: Some(date.as_str().to_string()),
393 key: Some(request.occurrence_key.as_str().to_string()),
394 rule: None,
395 state: None,
396 next_cursor: None,
397 skipped: Vec::new(),
398 });
399 occurrence.relationships.push(Relationship::new(
401 occurrence.number,
402 definition.number,
403 RelationKind::Related,
404 &definition.title,
405 ));
406 occurrence.history.append_now(
407 &request.actor,
408 HistoryEventKind::RelationshipAdded,
409 &format!(
410 "generated occurrence `{}` of series `{}` ({})",
411 request.occurrence_key, series.id, series.name
412 ),
413 );
414 let filename = store.update(&occurrence.filename, &occurrence)?;
415
416 let mut definition = definition.clone();
420 if let Some(reference) = definition.series.as_mut() {
421 reference.next_cursor = Some(date.as_str().to_string());
422 }
423 store.update(&definition.filename, &definition)?;
424 Ok((occurrence, filename))
425 });
426
427 match created {
428 Ok((occurrence, filename)) => Ok(GenerationOutcome::Created {
429 series_id: series.id.clone(),
430 key: request.occurrence_key.clone(),
431 date,
432 issue_number: occurrence.number,
433 filename,
434 }),
435 Err(error) => Ok(rejected(GenerationDiagnostic::new(
440 GenerationDiagnosticKind::Refused,
441 format!("the create core refused the occurrence: {error}"),
442 ))),
443 }
444}
445
446fn occurrence_title(series_title: &str, date: &DateKey) -> String {
450 format!("{series_title} ({date})")
451}
452
453fn rejected(diagnostic: GenerationDiagnostic) -> GenerationOutcome {
455 GenerationOutcome::Rejected {
456 diagnostics: vec![diagnostic],
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 #[allow(clippy::wildcard_imports)]
463 use super::*;
464
465 fn date_and_key() -> (DateKey, OccurrenceKey) {
468 let date = DateKey::from_date(
469 time::Date::from_calendar_date(2026, time::Month::January, 1).expect("a real date"),
470 );
471 let id = SeriesId::parse("s-abc").expect("a valid id");
472 let key = OccurrenceKey::new(&id, &date);
473 (date, key)
474 }
475
476 #[test]
477 fn a_request_is_unconfirmed_until_it_says_so() {
478 let (_, key) = date_and_key();
479 let request =
480 GenerationRequest::new(SeriesId::parse("s-abc").unwrap(), key.clone(), "alice");
481 assert!(!request.confirmed, "reading two ids is not consent");
482 let confirmed = request.clone().confirm();
483 assert!(confirmed.confirmed);
484 assert_eq!(confirmed.actor, "alice");
485 assert_eq!(confirmed.occurrence_key, key);
486 }
487
488 #[test]
489 fn an_occurrence_title_names_the_date_after_the_series_title() {
490 let (date, _) = date_and_key();
491 assert_eq!(
492 occurrence_title("Nightly close", &date),
493 "Nightly close (2026-01-01)"
494 );
495 }
496
497 #[test]
498 fn every_outcome_has_one_stable_word() {
499 let (date, key) = date_and_key();
500 let series_id = SeriesId::parse("s-abc").unwrap();
501
502 let created = GenerationOutcome::Created {
503 series_id: series_id.clone(),
504 key: key.clone(),
505 date: date.clone(),
506 issue_number: 7,
507 filename: "a.md".to_string(),
508 };
509 assert_eq!(created.as_str(), "created");
510 assert!(created.is_created());
511 assert!(created.render().contains("#7"));
512
513 let noop = GenerationOutcome::NoOp {
514 series_id,
515 key,
516 state: KeyState::Generated,
517 message: "already exists".to_string(),
518 };
519 assert_eq!(noop.as_str(), "no-op");
520 assert!(noop.is_noop());
521 assert!(noop.render().contains("already exists"));
522
523 let rejected = GenerationOutcome::Rejected {
524 diagnostics: vec![
525 GenerationDiagnostic::new(
526 GenerationDiagnosticKind::NotConfirmed,
527 "generation must be confirmed",
528 ),
529 GenerationDiagnostic::new(GenerationDiagnosticKind::UnknownSeries, "no series"),
530 ],
531 };
532 assert_eq!(rejected.as_str(), "rejected");
533 assert!(rejected.is_rejected());
534 assert!(
535 rejected.render().contains("not_confirmed"),
536 "{}",
537 rejected.render()
538 );
539 assert!(
540 rejected.render().contains("and 1 more"),
541 "a second reason is counted, not hidden: {}",
542 rejected.render()
543 );
544 }
545
546 #[test]
547 fn the_diagnostic_kinds_are_the_closed_set() {
548 let spellings: Vec<&str> = [
549 GenerationDiagnosticKind::NotConfirmed,
550 GenerationDiagnosticKind::MalformedKey,
551 GenerationDiagnosticKind::KeySeriesMismatch,
552 GenerationDiagnosticKind::UnknownSeries,
553 GenerationDiagnosticKind::SeriesNotActive,
554 GenerationDiagnosticKind::DefinitionUnreadable,
555 GenerationDiagnosticKind::Refused,
556 ]
557 .iter()
558 .map(|kind| kind.as_str())
559 .collect();
560 assert_eq!(
561 spellings,
562 vec![
563 "not_confirmed",
564 "malformed_occurrence_key",
565 "occurrence_key_series_mismatch",
566 "unknown_series",
567 "series_not_active",
568 "definition_unreadable",
569 "refused",
570 ]
571 );
572 }
573}