use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use time::{Date, Month};
use crate::capacity::calculate::CapacityCalculation;
use crate::capacity::snapshot::CapacitySnapshot;
use crate::config::{Config, PlanningCycle};
use crate::cycle_schedule::config::{CycleScheduleConfiguration, CycleScheduleId, ScheduleState};
use crate::cycle_schedule::identity::{
CycleReference, GeneratedCycle, GeneratedCycleId, GeneratedCycleState, ScheduleIndex,
cadence_rule,
};
use crate::cycle_schedule::snapshot::{
CycleSnapshot, CycleSnapshotMetadata, CycleSnapshotStore, SnapshotOccupant, validate_timestamp,
};
use crate::cycle_store::{CycleRecord, CycleStore};
use crate::error::{Result, TixError, render_diagnostics};
use crate::model::{History, HistoryEvent, HistoryEventKind};
use crate::records::RecordStore;
use crate::recurrence::rule::{RecurrenceRule, RuleContext, RuleKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LifecycleAction {
Close,
Reopen,
Extend,
Skip,
Pause,
Resume,
AdjustFuture,
Ignore,
}
impl LifecycleAction {
pub const ALL: [Self; 8] = [
Self::Close,
Self::Reopen,
Self::Extend,
Self::Skip,
Self::Pause,
Self::Resume,
Self::AdjustFuture,
Self::Ignore,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Close => "close",
Self::Reopen => "reopen",
Self::Extend => "extend",
Self::Skip => "skip",
Self::Pause => "pause",
Self::Resume => "resume",
Self::AdjustFuture => "adjust-future",
Self::Ignore => "ignore",
}
}
#[must_use]
pub const fn requires_reason(self) -> bool {
matches!(
self,
Self::Close | Self::Reopen | Self::Extend | Self::Skip | Self::AdjustFuture
)
}
#[must_use]
pub const fn writes(self) -> bool {
!matches!(self, Self::Ignore)
}
#[must_use]
const fn history_kind(self) -> Option<HistoryEventKind> {
match self {
Self::Close => Some(HistoryEventKind::CycleClosed),
Self::Reopen => Some(HistoryEventKind::CycleReopened),
Self::Extend => Some(HistoryEventKind::CycleExtended),
Self::Skip => Some(HistoryEventKind::CycleSkipped),
Self::Pause => Some(HistoryEventKind::CyclePaused),
Self::Resume => Some(HistoryEventKind::CycleResumed),
Self::AdjustFuture => Some(HistoryEventKind::CycleAdjusted),
Self::Ignore => None,
}
}
pub fn parse(raw: &str) -> Result<Self> {
match raw {
"close" => Ok(Self::Close),
"reopen" => Ok(Self::Reopen),
"extend" => Ok(Self::Extend),
"skip" => Ok(Self::Skip),
"pause" => Ok(Self::Pause),
"resume" => Ok(Self::Resume),
"adjust-future" => Ok(Self::AdjustFuture),
"ignore" => Ok(Self::Ignore),
other => Err(TixError::InvalidInput(format!(
"`{other}` is not a lifecycle action; expected one of `close`, `reopen`, `extend`, \
`skip`, `pause`, `resume`, `adjust-future`, `ignore`"
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LifecycleRequest {
pub action: LifecycleAction,
pub schedule_id: CycleScheduleId,
pub cycle_id: Option<GeneratedCycleId>,
pub actor: String,
pub reason: Option<String>,
pub timestamp: Option<String>,
pub confirmed: bool,
pub capacity: Option<CapacityCalculation>,
}
impl LifecycleRequest {
#[must_use]
pub fn new(
action: LifecycleAction,
schedule_id: CycleScheduleId,
actor: impl Into<String>,
) -> Self {
Self {
action,
schedule_id,
cycle_id: None,
actor: actor.into(),
reason: None,
timestamp: None,
confirmed: false,
capacity: None,
}
}
#[must_use]
pub fn with_capacity(mut self, calculation: CapacityCalculation) -> Self {
self.capacity = Some(calculation);
self
}
#[must_use]
pub fn confirm(mut self) -> Self {
self.confirmed = true;
self
}
#[must_use]
pub fn for_cycle(mut self, cycle_id: GeneratedCycleId) -> Self {
self.cycle_id = Some(cycle_id);
self
}
#[must_use]
pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
self.reason = Some(reason.into());
self
}
#[must_use]
pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
self.timestamp = Some(timestamp.into());
self
}
fn reason_text(&self) -> &str {
self.reason.as_deref().map(str::trim).unwrap_or_default()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LifecycleDiagnosticKind {
NotConfirmed,
MissingReason,
InvalidReason,
InvalidActor,
UnknownSchedule,
UnknownCycle,
MissingCycle,
CycleNotOwned,
MissingActor,
MissingTimestamp,
InvalidTimestamp,
TerminalSchedule,
TerminalCycle,
MissingCadence,
InvalidCadence,
InvalidDuration,
UnresolvableDate,
MissingSequence,
SnapshotCollision,
ReferenceCollision,
NameCollision,
SchedulingFailed,
ConfigNotPersisted,
}
impl LifecycleDiagnosticKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NotConfirmed => "not_confirmed",
Self::MissingReason => "missing_reason",
Self::InvalidReason => "invalid_reason",
Self::InvalidActor => "invalid_actor",
Self::UnknownSchedule => "unknown_schedule",
Self::UnknownCycle => "unknown_cycle",
Self::MissingCycle => "missing_cycle",
Self::CycleNotOwned => "cycle_not_owned",
Self::MissingActor => "missing_actor",
Self::MissingTimestamp => "missing_timestamp",
Self::InvalidTimestamp => "invalid_timestamp",
Self::TerminalSchedule => "terminal_schedule",
Self::TerminalCycle => "terminal_cycle",
Self::MissingCadence => "missing_cadence",
Self::InvalidCadence => "invalid_cadence",
Self::InvalidDuration => "invalid_duration",
Self::UnresolvableDate => "unresolvable_date",
Self::MissingSequence => "missing_sequence",
Self::SnapshotCollision => "snapshot_collision",
Self::ReferenceCollision => "reference_collision",
Self::NameCollision => "name_collision",
Self::SchedulingFailed => "scheduling_failure",
Self::ConfigNotPersisted => "config_not_persisted",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LifecycleDiagnostic {
pub kind: LifecycleDiagnosticKind,
pub field: String,
pub message: String,
}
impl LifecycleDiagnostic {
fn new(
kind: LifecycleDiagnosticKind,
field: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
kind,
field: field.into(),
message: message.into(),
}
}
#[must_use]
pub fn render(&self) -> String {
render_diagnostics(
"E-tix-cycle-schedule",
std::iter::once((self.kind.as_str(), self.message.as_str())),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LifecycleOutcome {
Applied {
action: LifecycleAction,
schedule_id: CycleScheduleId,
cycle_id: Option<GeneratedCycleId>,
snapshot_path: Option<String>,
dates: Option<(String, String)>,
message: String,
},
NoOp {
action: LifecycleAction,
schedule_id: CycleScheduleId,
cycle_id: Option<GeneratedCycleId>,
message: String,
},
Rejected {
action: LifecycleAction,
schedule_id: CycleScheduleId,
cycle_id: Option<GeneratedCycleId>,
diagnostics: Vec<LifecycleDiagnostic>,
},
}
impl LifecycleOutcome {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Applied { .. } => "applied",
Self::NoOp { .. } => "no-op",
Self::Rejected { .. } => "rejected",
}
}
#[must_use]
pub fn is_applied(&self) -> bool {
matches!(self, Self::Applied { .. })
}
#[must_use]
pub fn is_noop(&self) -> bool {
matches!(self, Self::NoOp { .. })
}
#[must_use]
pub fn is_rejected(&self) -> bool {
matches!(self, Self::Rejected { .. })
}
#[must_use]
pub fn action(&self) -> LifecycleAction {
match self {
Self::Applied { action, .. }
| Self::NoOp { action, .. }
| Self::Rejected { action, .. } => *action,
}
}
#[must_use]
pub fn schedule_id(&self) -> &CycleScheduleId {
match self {
Self::Applied { schedule_id, .. }
| Self::NoOp { schedule_id, .. }
| Self::Rejected { schedule_id, .. } => schedule_id,
}
}
#[must_use]
pub fn cycle_id(&self) -> Option<&GeneratedCycleId> {
match self {
Self::Applied { cycle_id, .. }
| Self::NoOp { cycle_id, .. }
| Self::Rejected { cycle_id, .. } => cycle_id.as_ref(),
}
}
#[must_use]
pub fn snapshot_path(&self) -> Option<&str> {
match self {
Self::Applied { snapshot_path, .. } => snapshot_path.as_deref(),
Self::NoOp { .. } | Self::Rejected { .. } => None,
}
}
#[must_use]
pub fn dates(&self) -> Option<&(String, String)> {
match self {
Self::Applied { dates, .. } => dates.as_ref(),
Self::NoOp { .. } | Self::Rejected { .. } => None,
}
}
#[must_use]
pub fn diagnostics(&self) -> &[LifecycleDiagnostic] {
match self {
Self::Rejected { diagnostics, .. } => diagnostics,
Self::Applied { .. } | Self::NoOp { .. } => &[],
}
}
#[must_use]
pub fn render(&self) -> String {
match self {
Self::Applied {
action,
cycle_id,
snapshot_path,
dates,
message,
..
} => {
let mut line = format!("{} `{}` applied", action.as_str(), self.schedule_id());
if let Some(cycle_id) = cycle_id {
line.push_str(&format!(" to `{cycle_id}`"));
}
if let Some(path) = snapshot_path {
line.push_str(&format!(" (snapshot `{path}`)"));
}
if let Some((start, due)) = dates {
line.push_str(&format!(" ({start} → {due})"));
}
format!("{line}: {message}")
}
Self::NoOp {
action, message, ..
} => {
format!(
"{} `{}` no-op: {message}",
action.as_str(),
self.schedule_id()
)
}
Self::Rejected { diagnostics, .. } => {
let first = diagnostics
.first()
.map(LifecycleDiagnostic::render)
.unwrap_or_else(|| "rejected".to_string());
if diagnostics.len() > 1 {
format!("{first} (and {} more)", diagnostics.len() - 1)
} else {
first
}
}
}
}
}
pub fn apply(
config: &mut Config,
root: &Path,
request: &LifecycleRequest,
) -> Result<LifecycleOutcome> {
let action = request.action;
let schedule_id = request.schedule_id.clone();
if !request.confirmed {
return Ok(refused(
action,
schedule_id,
request.cycle_id.clone(),
LifecycleDiagnosticKind::NotConfirmed,
format!("cycle_schedules.{}", request.schedule_id),
"a lifecycle action must be confirmed: detection reports a warning, a caller or person \
decides, and only then is anything written",
));
}
if action.requires_reason() && request.reason_text().is_empty() {
return Ok(refused(
action,
schedule_id,
request.cycle_id.clone(),
LifecycleDiagnosticKind::MissingReason,
format!("cycle_schedules.{}.reason", request.schedule_id),
format!(
"`{}` records a reason; supply one so the decision is auditable",
action.as_str()
),
));
}
if !request.reason_text().is_empty()
&& let Some(hazard) = history_text_hazard(request.reason_text(), true)
{
return Ok(refused(
action,
schedule_id,
request.cycle_id.clone(),
LifecycleDiagnosticKind::InvalidReason,
format!("cycle_schedules.{}.reason", request.schedule_id),
format!(
"the reason contains {hazard}; a `## History` entry is one line, so a reason \
carrying one would render as more than one entry and a reader would take the \
extra line for an event nobody wrote"
),
));
}
if action.writes() {
let actor = request.actor.trim();
if actor.is_empty() {
return Ok(refused(
action,
schedule_id,
request.cycle_id.clone(),
LifecycleDiagnosticKind::MissingActor,
format!("cycle_schedules.{}.actor", request.schedule_id),
format!(
"`{}` records who decided; the actor is blank",
action.as_str()
),
));
}
if let Some(hazard) = history_text_hazard(actor, false) {
return Ok(refused(
action,
schedule_id,
request.cycle_id.clone(),
LifecycleDiagnosticKind::InvalidActor,
format!("cycle_schedules.{}.actor", request.schedule_id),
format!(
"the actor contains {hazard}; a `## History` entry is one line, so an actor \
carrying one would render as more than one entry and a reader would take the \
extra line for an event nobody wrote"
),
));
}
let Some(timestamp) = request.timestamp.as_deref().map(str::trim) else {
return Ok(refused(
action,
schedule_id,
request.cycle_id.clone(),
LifecycleDiagnosticKind::MissingTimestamp,
format!("cycle_schedules.{}.timestamp", request.schedule_id),
format!(
"`{}` records when it happened; supply an explicit RFC 3339 timestamp",
action.as_str()
),
));
};
if !validate_timestamp(timestamp) {
return Ok(refused(
action,
schedule_id,
request.cycle_id.clone(),
LifecycleDiagnosticKind::InvalidTimestamp,
format!("cycle_schedules.{}.timestamp", request.schedule_id),
format!(
"`{timestamp}` is not a `YYYY-MM-DDTHH:MM:SSZ` instant; an audit time is \
evidence and is never invented"
),
));
}
}
let store = CycleStore::new(root);
let index = ScheduleIndex::build(config, &store.list()?);
let Some(schedule) = index.schedule(&schedule_id).cloned() else {
return Ok(refused(
action,
schedule_id,
request.cycle_id.clone(),
LifecycleDiagnosticKind::UnknownSchedule,
format!("cycle_schedules.{}", request.schedule_id),
format!("no cycle schedule `{}` is defined", request.schedule_id),
));
};
match action {
LifecycleAction::Close => close(&store, root, &index, request),
LifecycleAction::Reopen => reopen(root, &store, &index, request),
LifecycleAction::Skip => skip(&store, &index, &schedule, request),
LifecycleAction::Extend => extend(config, &store, &index, &schedule, request),
LifecycleAction::Pause => set_state(
config,
root,
&store,
&index,
&schedule,
request,
(ScheduleState::Paused, GeneratedCycleState::Paused),
),
LifecycleAction::Resume => set_state(
config,
root,
&store,
&index,
&schedule,
request,
(ScheduleState::Active, GeneratedCycleState::Active),
),
LifecycleAction::AdjustFuture => {
adjust_future(config, root, &store, &index, &schedule, request)
}
LifecycleAction::Ignore => Ok(ignore(&index, request)),
}
}
fn close(
store: &CycleStore,
root: &Path,
index: &ScheduleIndex,
request: &LifecycleRequest,
) -> Result<LifecycleOutcome> {
let action = LifecycleAction::Close;
let schedule_id = request.schedule_id.clone();
let Some(cycle_id) = request.cycle_id.clone() else {
return Ok(missing_cycle(action, schedule_id, "closing"));
};
if cycle_id.schedule_id() != schedule_id.as_str() {
return Ok(not_owned(action, schedule_id, cycle_id));
}
let Some(cycle) = index.cycle(&cycle_id).cloned() else {
return Ok(unknown_cycle(action, schedule_id, cycle_id));
};
match cycle.state {
GeneratedCycleState::Closed => {
return Ok(no_op(
action,
schedule_id,
Some(cycle_id),
"the instance is already closed; its snapshot and event are the record and nothing \
was written",
));
}
GeneratedCycleState::Skipped => {
return Ok(refused(
action,
schedule_id,
Some(cycle_id.clone()),
LifecycleDiagnosticKind::TerminalCycle,
format!("cycles.{cycle_id}.state"),
format!(
"cycle `{cycle_id}` is skipped, which is terminal; a skipped sequence is not \
closed, it stays recorded"
),
));
}
GeneratedCycleState::Active | GeneratedCycleState::Paused => {}
}
let stored = read_stored(store, &cycle.name);
let snapshots = CycleSnapshotStore::new(root);
let already = match snapshots.occupant(&cycle_id)? {
SnapshotOccupant::Same(_) => true,
SnapshotOccupant::Foreign { filename } => {
return Ok(refused(
action,
schedule_id,
Some(cycle_id.clone()),
LifecycleDiagnosticKind::SnapshotCollision,
format!("docs/cycle_snapshots/{filename}"),
format!(
"`docs/cycle_snapshots/{filename}` already exists and is not `{cycle_id}`'s \
snapshot; a snapshot is immutable, so nothing was overwritten"
),
));
}
SnapshotOccupant::Absent => false,
};
let measurements =
request
.capacity
.as_ref()
.map_or_else(CycleSnapshotMetadata::new, |calculation| {
CapacitySnapshot::capture(
&cycle_id,
request.timestamp.as_deref().unwrap_or_default(),
&request.actor,
calculation,
)
.to_measurements()
});
let snapshot = CycleSnapshot {
cycle_id: cycle_id.clone(),
schedule_id: schedule_id.clone(),
sequence: cycle.sequence,
start_date: cycle.start_date.clone(),
due_date: cycle.due_date.clone(),
closed_at: request
.timestamp
.as_deref()
.unwrap_or_default()
.trim()
.to_string(),
closed_by: request.actor.trim().to_string(),
state_summary: GeneratedCycleState::Closed.as_str().to_string(),
measurements,
extensions: CycleSnapshotMetadata::new(),
};
let snapshot_dirs = missing_ancestors(snapshots.dir());
let mut wrote_snapshot = false;
if !already {
snapshots.capture(&snapshot)?;
wrote_snapshot = true;
}
let path = format!(
"docs/cycle_snapshots/{}",
CycleSnapshotStore::filename_for(&cycle_id)
);
let mut record = base_record(&cycle, stored.as_ref());
record.state = Some(GeneratedCycleState::Closed.as_str().to_string());
let body = append_event(
stored_body(stored.as_ref()),
request,
HistoryEventKind::CycleClosed,
&format!(
"reason: {}; state: closed; snapshot: {path}",
request.reason_text()
),
);
if let Err(error) = write_cycle(store, &record, &body) {
let mut undone = Vec::new();
if wrote_snapshot {
if let Err(failure) = snapshots.discard(&cycle_id) {
undone.push(format!("the snapshot `{path}` was left behind: {failure}"));
} else {
remove_empty_dirs(&snapshot_dirs);
}
}
let tail = if !wrote_snapshot {
"no snapshot was written by this call, so the snapshot already on disk is \
untouched and only the record is unchanged"
.to_string()
} else if undone.is_empty() {
"the snapshot was removed again and the record is unchanged".to_string()
} else {
undone.join("; ")
};
return Ok(refused(
action,
schedule_id,
Some(cycle_id.clone()),
LifecycleDiagnosticKind::SchedulingFailed,
format!("cycles.{cycle_id}"),
format!("the cycle record for `{cycle_id}` could not be written: {error}; {tail}"),
));
}
let message = if already {
format!("marked `{cycle_id}` closed; its snapshot was already captured and is unchanged")
} else {
format!("closed `{cycle_id}` and captured `{path}`")
};
Ok(LifecycleOutcome::Applied {
action,
schedule_id,
cycle_id: Some(cycle_id),
snapshot_path: Some(path),
dates: None,
message,
})
}
fn reopen(
root: &Path,
store: &CycleStore,
index: &ScheduleIndex,
request: &LifecycleRequest,
) -> Result<LifecycleOutcome> {
let action = LifecycleAction::Reopen;
let schedule_id = request.schedule_id.clone();
let Some(cycle_id) = request.cycle_id.clone() else {
return Ok(missing_cycle(action, schedule_id, "reopening"));
};
if cycle_id.schedule_id() != schedule_id.as_str() {
return Ok(not_owned(action, schedule_id, cycle_id));
}
let Some(cycle) = index.cycle(&cycle_id).cloned() else {
return Ok(unknown_cycle(action, schedule_id, cycle_id));
};
match cycle.state {
GeneratedCycleState::Active | GeneratedCycleState::Paused => {
return Ok(no_op(
action,
schedule_id,
Some(cycle_id),
"the instance is not closed; reopening an open cycle is a no-op and nothing was \
written",
));
}
GeneratedCycleState::Skipped => {
return Ok(refused(
action,
schedule_id,
Some(cycle_id.clone()),
LifecycleDiagnosticKind::TerminalCycle,
format!("cycles.{cycle_id}.state"),
format!(
"cycle `{cycle_id}` is skipped, which is terminal; a skipped sequence was \
never generated, so there is nothing to reopen"
),
));
}
GeneratedCycleState::Closed => {}
}
let prior = CapacitySnapshot::read(&CycleSnapshotStore::new(root), &cycle_id)?;
let (kind, detail) =
crate::capacity::snapshot::reopen_event(request.reason_text(), prior.as_ref())
.expect("`apply` refuses a missing reason before any action runs");
let stored = read_stored(store, &cycle.name);
let mut record = base_record(&cycle, stored.as_ref());
record.state = Some(GeneratedCycleState::Active.as_str().to_string());
let body = append_event(stored_body(stored.as_ref()), request, kind, &detail);
if let Err(error) = write_cycle(store, &record, &body) {
return Ok(refused(
action,
schedule_id,
Some(cycle_id.clone()),
LifecycleDiagnosticKind::SchedulingFailed,
format!("cycles.{cycle_id}"),
format!(
"the cycle record for `{cycle_id}` could not be written: {error}; no event was \
appended and the snapshot on disk is untouched"
),
));
}
let message = match prior.as_ref() {
Some(evidence) => format!(
"reopened `{cycle_id}`; the snapshot `{}` is unchanged and stays readable, and the \
current calculation is derived again",
evidence.snapshot_id
),
None => format!(
"reopened `{cycle_id}`; it has no capacity snapshot, so there is none to preserve"
),
};
Ok(LifecycleOutcome::Applied {
action,
schedule_id,
cycle_id: Some(cycle_id),
snapshot_path: None,
dates: None,
message,
})
}
fn skip(
store: &CycleStore,
index: &ScheduleIndex,
schedule: &CycleScheduleConfiguration,
request: &LifecycleRequest,
) -> Result<LifecycleOutcome> {
let action = LifecycleAction::Skip;
let schedule_id = request.schedule_id.clone();
let Some(cycle_id) = request.cycle_id.clone() else {
return Ok(missing_cycle(action, schedule_id, "skipping"));
};
if cycle_id.schedule_id() != schedule_id.as_str() {
return Ok(not_owned(action, schedule_id, cycle_id));
}
let (stored, mut record) = if let Some(cycle) = index.cycle(&cycle_id).cloned() {
match cycle.state {
GeneratedCycleState::Skipped => {
return Ok(no_op(
action,
schedule_id,
Some(cycle_id),
"the sequence is already recorded as skipped; nothing was written",
));
}
GeneratedCycleState::Closed => {
return Ok(refused(
action,
schedule_id,
Some(cycle_id.clone()),
LifecycleDiagnosticKind::TerminalCycle,
format!("cycles.{cycle_id}.state"),
format!(
"cycle `{cycle_id}` is closed, which is terminal; a closed cycle cannot be \
skipped"
),
));
}
GeneratedCycleState::Active | GeneratedCycleState::Paused => {}
}
let stored = read_stored(store, &cycle.name);
let record = base_record(&cycle, stored.as_ref());
(stored, record)
} else {
let sequence = cycle_id.sequence();
let next = index.cursor(&schedule_id).next_sequence.unwrap_or(1);
if sequence > next {
return Ok(refused(
action,
schedule_id.clone(),
Some(cycle_id.clone()),
LifecycleDiagnosticKind::MissingSequence,
format!("cycle_schedules.{schedule_id}.cursor"),
format!(
"sequence {sequence} of `{schedule_id}` is not the next candidate and no \
record exists for sequence {next}; skipping it would leave {next} unrecorded \
and generation never creates a sequence beyond a gap — record {next} first"
),
));
}
let Some(rule) = cadence_rule(schedule) else {
return Ok(missing_cadence(action, schedule_id, Some(cycle_id)));
};
if let Err(error) = rule.validate() {
return Ok(invalid_cadence(
action,
schedule_id,
Some(cycle_id),
&error,
request,
));
}
let Some(start) = nth_occurrence(&rule, sequence) else {
return Ok(unresolvable(action, schedule_id, Some(cycle_id)));
};
let due = match nominal_due(&schedule_id, schedule, &start) {
Ok(due) => due,
Err(diagnostic) => {
return Ok(LifecycleOutcome::Rejected {
action,
schedule_id,
cycle_id: Some(cycle_id),
diagnostics: vec![diagnostic],
});
}
};
let name = reference_name(&schedule.name, &start, sequence);
if !matches!(index.resolve(&name), CycleReference::Unknown { .. }) {
return Ok(refused(
action,
schedule_id,
Some(cycle_id),
LifecycleDiagnosticKind::ReferenceCollision,
format!("cycles.{name}"),
format!(
"the reference label `{name}` already answers to another cycle; rename that \
cycle before skipping this sequence"
),
));
}
let filename = format!("{}.md", crate::slug::slugify(&name));
if store.dir().join(&filename).exists() {
return Ok(refused(
action,
schedule_id,
Some(cycle_id.clone()),
LifecycleDiagnosticKind::NameCollision,
format!("docs/cycles/{filename}"),
format!(
"`docs/cycles/{filename}` is already occupied and is not `{cycle_id}`; \
refusing to lose whatever is there"
),
));
}
let record = CycleRecord {
name,
description: None,
start_date: Some(start.clone()),
due_date: Some(due.clone()),
schedule_id: Some(schedule_id.as_str().to_string()),
sequence: Some(sequence),
state: Some(GeneratedCycleState::Skipped.as_str().to_string()),
};
(None, record)
};
record.state = Some(GeneratedCycleState::Skipped.as_str().to_string());
let dates = format!(
"{}->{}",
record.start_date.as_deref().unwrap_or_default(),
record.due_date.as_deref().unwrap_or_default()
);
let body = append_event(
stored_body(stored.as_ref()),
request,
HistoryEventKind::CycleSkipped,
&format!(
"reason: {}; state: skipped; dates: {dates}",
request.reason_text()
),
);
if let Err(error) = write_cycle(store, &record, &body) {
return Ok(refused(
action,
schedule_id,
Some(cycle_id.clone()),
LifecycleDiagnosticKind::SchedulingFailed,
format!("cycles.{cycle_id}"),
format!("the cycle record for `{cycle_id}` could not be written: {error}"),
));
}
Ok(LifecycleOutcome::Applied {
action,
schedule_id,
cycle_id: Some(cycle_id),
snapshot_path: None,
dates: None,
message: "`".to_string() + &record.name + "` is now recorded as skipped",
})
}
fn extend(
config: &mut Config,
store: &CycleStore,
index: &ScheduleIndex,
schedule: &CycleScheduleConfiguration,
request: &LifecycleRequest,
) -> Result<LifecycleOutcome> {
let action = LifecycleAction::Extend;
let schedule_id = request.schedule_id.clone();
let Some(cycle_id) = request.cycle_id.clone() else {
return Ok(missing_cycle(action, schedule_id, "extending"));
};
if cycle_id.schedule_id() != schedule_id.as_str() {
return Ok(not_owned(action, schedule_id, cycle_id));
}
let Some(cycle) = index.cycle(&cycle_id).cloned() else {
return Ok(unknown_cycle(action, schedule_id, cycle_id));
};
if cycle.state.is_terminal() {
return Ok(refused(
action,
schedule_id,
Some(cycle_id.clone()),
LifecycleDiagnosticKind::TerminalCycle,
format!("cycles.{cycle_id}.state"),
format!(
"cycle `{cycle_id}` is {}, which is terminal; it cannot be extended",
cycle.state.as_str()
),
));
}
let Some(rule) = cadence_rule(schedule) else {
return Ok(missing_cadence(action, schedule_id, Some(cycle_id)));
};
if let Err(error) = rule.validate() {
return Ok(invalid_cadence(
action,
schedule_id,
Some(cycle_id),
&error,
request,
));
}
let Some(next_start) = step_from(&rule, &cycle.start_date) else {
return Ok(unresolvable(action, schedule_id, Some(cycle_id)));
};
let target_due = match nominal_due(&schedule_id, schedule, &next_start) {
Ok(due) => due,
Err(diagnostic) => {
return Ok(LifecycleOutcome::Rejected {
action,
schedule_id,
cycle_id: Some(cycle_id),
diagnostics: vec![diagnostic],
});
}
};
if cycle.due_date == target_due {
return Ok(no_op(
action,
schedule_id,
Some(cycle_id),
format!("the instance already ends {target_due}; extending is idempotent"),
));
}
let stored = read_stored(store, &cycle.name);
let mut record = base_record(&cycle, stored.as_ref());
record.due_date = Some(target_due.clone());
let body = append_event(
stored_body(stored.as_ref()),
request,
HistoryEventKind::CycleExtended,
&format!("reason: {}; due: {target_due}", request.reason_text()),
);
if let Err(error) = write_cycle(store, &record, &body) {
return Ok(refused(
action,
schedule_id,
Some(cycle_id.clone()),
LifecycleDiagnosticKind::SchedulingFailed,
format!("cycles.{cycle_id}"),
format!("the cycle record for `{cycle_id}` could not be written: {error}"),
));
}
config
.cycles
.insert(record.name.clone(), PlanningCycle::from(record));
Ok(LifecycleOutcome::Applied {
action,
schedule_id,
cycle_id: Some(cycle_id.clone()),
snapshot_path: None,
dates: Some((cycle.start_date, target_due)),
message: format!("extended `{cycle_id}` by one cadence period"),
})
}
fn set_state(
config: &mut Config,
root: &Path,
store: &CycleStore,
index: &ScheduleIndex,
schedule: &CycleScheduleConfiguration,
request: &LifecycleRequest,
target: (ScheduleState, GeneratedCycleState),
) -> Result<LifecycleOutcome> {
let (schedule_state, cycle_state) = target;
let action = request.action;
let schedule_id = request.schedule_id.clone();
if schedule.state.is_terminal() {
return Ok(refused(
action,
schedule_id,
request.cycle_id.clone(),
LifecycleDiagnosticKind::TerminalSchedule,
format!("cycle_schedules.{}.state", request.schedule_id),
format!(
"schedule `{}` is ended, which is terminal; `{}` is refused for good",
request.schedule_id,
action.as_str()
),
));
}
let targets: Vec<GeneratedCycle> = match request.cycle_id.clone() {
Some(cycle_id) => {
if cycle_id.schedule_id() != schedule_id.as_str() {
return Ok(not_owned(action, schedule_id, cycle_id));
}
let Some(cycle) = index.cycle(&cycle_id).cloned() else {
return Ok(unknown_cycle(action, schedule_id, cycle_id));
};
if cycle.state.is_terminal() {
return Ok(refused(
action,
schedule_id,
Some(cycle_id.clone()),
LifecycleDiagnosticKind::TerminalCycle,
format!("cycles.{cycle_id}.state"),
format!(
"cycle `{cycle_id}` is {}, which is terminal; the action names it",
cycle.state.as_str()
),
));
}
vec![cycle]
}
None => index
.cycles_of(&schedule_id)
.filter(|cycle| !cycle.state.is_terminal())
.cloned()
.collect(),
};
let instances_at_target = targets.iter().all(|cycle| cycle.state == cycle_state);
if schedule.state == schedule_state && instances_at_target {
return Ok(no_op(
action,
schedule_id,
request.cycle_id.clone(),
format!(
"the schedule is already {} and every affected instance matches",
schedule_state.as_str()
),
));
}
let kind = action.history_kind().expect("pause/resume write events");
let detail = match request.reason_text() {
"" => format!("state: {}", schedule_state.as_str()),
reason => format!("state: {}; reason: {reason}", schedule_state.as_str()),
};
let mut written: Vec<(String, Option<StoredCycle>)> = Vec::new();
for cycle in &targets {
if cycle.state == cycle_state {
continue;
}
let stored = read_stored(store, &cycle.name);
let mut record = base_record(cycle, stored.as_ref());
record.state = Some(cycle_state.as_str().to_string());
let body = append_event(stored_body(stored.as_ref()), request, kind, &detail);
if let Err(error) = write_cycle(store, &record, &body) {
let undone = roll_back(store, &written);
return Ok(refused(
action,
schedule_id,
request.cycle_id.clone(),
LifecycleDiagnosticKind::SchedulingFailed,
format!("cycles.{}", cycle.id),
format!(
"a cycle record could not be written: {error}; {}",
roll_back_tail(&undone, written.len())
),
));
}
written.push((record.name.clone(), stored));
}
if schedule.state != schedule_state {
if let Some(entry) = config.cycle_schedules.get_mut(schedule_id.as_str()) {
entry.state = schedule_state;
}
let config_path = root.join("tix.toml");
if let Err(error) = config.save(&config_path) {
let undone = roll_back(store, &written);
if let Some(entry) = config.cycle_schedules.get_mut(schedule_id.as_str()) {
entry.state = schedule.state;
}
return Ok(refused_rollback(
action,
schedule_id,
request.cycle_id.clone(),
&config_path.display().to_string(),
&error,
(!undone.is_empty()).then(|| undone.join("; ")),
));
}
}
Ok(LifecycleOutcome::Applied {
action,
schedule_id,
cycle_id: request.cycle_id.clone(),
snapshot_path: None,
dates: None,
message: format!(
"schedule is now {}; {} instance record(s) updated",
schedule_state.as_str(),
written.len()
),
})
}
fn adjust_future(
config: &mut Config,
root: &Path,
store: &CycleStore,
index: &ScheduleIndex,
schedule: &CycleScheduleConfiguration,
request: &LifecycleRequest,
) -> Result<LifecycleOutcome> {
let action = LifecycleAction::AdjustFuture;
let schedule_id = request.schedule_id.clone();
let from_sequence = match request.cycle_id.clone() {
Some(cycle_id) => {
if cycle_id.schedule_id() != schedule_id.as_str() {
return Ok(not_owned(action, schedule_id, cycle_id));
}
let Some(cycle) = index.cycle(&cycle_id).cloned() else {
return Ok(unknown_cycle(action, schedule_id, cycle_id));
};
cycle.sequence
}
None => 1,
};
let Some(rule) = cadence_rule(schedule) else {
return Ok(missing_cadence(
action,
schedule_id,
request.cycle_id.clone(),
));
};
if let Err(error) = rule.validate() {
return Ok(invalid_cadence(
action,
schedule_id,
request.cycle_id.clone(),
&error,
request,
));
}
let kind = action
.history_kind()
.expect("adjust-future writes an event");
let mut plans: Vec<(String, Option<StoredCycle>, CycleRecord, String)> = Vec::new();
for cycle in index.cycles_of(&schedule_id) {
if cycle.state.is_terminal() || cycle.sequence < from_sequence {
continue;
}
let Some(start) = nth_occurrence(&rule, cycle.sequence) else {
return Ok(unresolvable(action, schedule_id, Some(cycle.id.clone())));
};
let due = match nominal_due(&schedule_id, schedule, &start) {
Ok(due) => due,
Err(diagnostic) => {
return Ok(LifecycleOutcome::Rejected {
action,
schedule_id,
cycle_id: Some(cycle.id.clone()),
diagnostics: vec![diagnostic],
});
}
};
if start == cycle.start_date && due == cycle.due_date {
continue;
}
let stored = read_stored(store, &cycle.name);
let mut record = stored
.as_ref()
.map_or_else(|| cycle.to_record(), |stored| stored.record.clone());
record.start_date = Some(start.clone());
record.due_date = Some(due.clone());
let body = append_event(
stored_body(stored.as_ref()),
request,
kind,
&format!("reason: {}; dates: {start}->{due}", request.reason_text()),
);
plans.push((cycle.name.clone(), stored, record, body));
}
let by_name: BTreeMap<String, CycleRecord> = plans
.iter()
.map(|(name, _, record, _)| (name.clone(), record.clone()))
.collect();
let projected: Vec<CycleRecord> = store
.list()?
.into_iter()
.map(|record| by_name.get(&record.name).cloned().unwrap_or(record))
.collect();
let derived = ScheduleIndex::build(config, &projected).cursor(&schedule_id);
let previous_cursor = config
.cycle_schedules
.get(schedule_id.as_str())
.and_then(|record| record.cursor.clone());
if plans.is_empty() && previous_cursor.as_ref() == Some(&derived) {
return Ok(no_op(
action,
schedule_id,
request.cycle_id.clone(),
"every future instance already matches the schedule and the cursor is current",
));
}
let mut written: Vec<(String, Option<StoredCycle>)> = Vec::new();
for (name, stored, record, body) in &plans {
if let Err(error) = write_cycle(store, record, body) {
let undone = roll_back(store, &written);
return Ok(refused(
action,
schedule_id,
request.cycle_id.clone(),
LifecycleDiagnosticKind::SchedulingFailed,
format!("cycles.{name}"),
format!(
"a future instance record could not be written: {error}; {}",
roll_back_tail(&undone, written.len())
),
));
}
written.push((name.clone(), stored.clone()));
}
let previous_cycles: Vec<(String, Option<PlanningCycle>)> = plans
.iter()
.map(|(name, _, _, _)| (name.clone(), config.cycles.get(name).cloned()))
.collect();
for (name, _, record, _) in &plans {
config
.cycles
.insert(name.clone(), PlanningCycle::from(record.clone()));
}
if let Some(entry) = config.cycle_schedules.get_mut(schedule_id.as_str()) {
entry.cursor = Some(derived.clone());
}
let config_path = root.join("tix.toml");
if let Err(error) = config.save(&config_path) {
let undone = roll_back(store, &written);
for (name, previous) in previous_cycles {
match previous {
Some(planning) => {
config.cycles.insert(name, planning);
}
None => {
config.cycles.shift_remove(&name);
}
}
}
if let Some(entry) = config.cycle_schedules.get_mut(schedule_id.as_str()) {
entry.cursor = previous_cursor;
}
return Ok(refused_rollback(
action,
schedule_id,
request.cycle_id.clone(),
&config_path.display().to_string(),
&error,
(!undone.is_empty()).then(|| undone.join("; ")),
));
}
let dates = plans.last().map(|(_, _, record, _)| {
(
record.start_date.clone().unwrap_or_default(),
record.due_date.clone().unwrap_or_default(),
)
});
Ok(LifecycleOutcome::Applied {
action,
schedule_id,
cycle_id: request.cycle_id.clone(),
snapshot_path: None,
dates,
message: format!(
"re-derived {} future instance(s) from the schedule and refreshed the cursor",
plans.len()
),
})
}
fn ignore(index: &ScheduleIndex, request: &LifecycleRequest) -> LifecycleOutcome {
let action = LifecycleAction::Ignore;
let schedule_id = request.schedule_id.clone();
if let Some(cycle_id) = request.cycle_id.clone() {
if cycle_id.schedule_id() != schedule_id.as_str() {
return not_owned(action, schedule_id, cycle_id);
}
if index.cycle(&cycle_id).is_none() {
return unknown_cycle(action, schedule_id, cycle_id);
}
}
no_op(
action,
schedule_id,
request.cycle_id.clone(),
"ignore records nothing: the warning is closed and no file, cursor, record or snapshot \
is written",
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct StoredCycle {
record: CycleRecord,
body: String,
}
fn read_stored(store: &CycleStore, name: &str) -> Option<StoredCycle> {
RecordStore::new(store.dir())
.get::<CycleRecord>(name)
.ok()
.map(|file| StoredCycle {
record: file.value,
body: file.body,
})
}
fn stored_body(stored: Option<&StoredCycle>) -> &str {
stored.map_or("", |stored| stored.body.as_str())
}
fn base_record(cycle: &GeneratedCycle, stored: Option<&StoredCycle>) -> CycleRecord {
let mut record = match stored {
Some(stored) => stored.record.clone(),
None => cycle.to_record(),
};
record.name = cycle.name.clone();
record.schedule_id = Some(cycle.schedule_id.as_str().to_string());
record.sequence = Some(cycle.sequence);
record.state = Some(cycle.state.as_str().to_string());
record.start_date = Some(cycle.start_date.clone());
record.due_date = Some(cycle.due_date.clone());
record
}
fn append_event(
body: &str,
request: &LifecycleRequest,
kind: HistoryEventKind,
detail: &str,
) -> String {
let (mut history, rest) = History::parse_front(body);
history.append(HistoryEvent::new(
request.timestamp.as_deref().unwrap_or_default().trim(),
request.actor.trim(),
kind,
detail,
));
format!("{}{rest}", history.render())
}
fn write_cycle(store: &CycleStore, record: &CycleRecord, body: &str) -> Result<()> {
store.save_record(record, Some(body))
}
fn history_text_hazard(text: &str, check_separator: bool) -> Option<&'static str> {
if text.contains('\n') || text.contains('\r') {
Some("a line break")
} else if check_separator && text.contains('\u{2014}') {
Some("the em dash (U+2014) a `## History` entry separates its fields with")
} else {
None
}
}
pub fn append_cycle_event(
root: &Path,
cycle_id: &GeneratedCycleId,
actor: &str,
timestamp: &str,
kind: HistoryEventKind,
detail: &str,
) -> Result<bool> {
if !is_cycle_history_kind(kind) {
return Err(TixError::InvalidInput(format!(
"`{kind}` is not a cycle lifecycle event; a cycle record's `## History` carries only \
the cycle kinds, so an issue's vocabulary is refused rather than written into the \
wrong log"
)));
}
let actor = actor.trim();
if actor.is_empty() {
return Err(TixError::InvalidInput(
"a closed-cycle change records who decided; the actor is blank".into(),
));
}
if let Some(hazard) = history_text_hazard(actor, false) {
return Err(TixError::InvalidInput(format!(
"the actor contains {hazard}; a `## History` entry is one line, so an actor carrying \
one would render as more than one entry and a reader would take the extra line for an \
event nobody wrote"
)));
}
if let Some(hazard) = history_text_hazard(detail, true) {
return Err(TixError::InvalidInput(format!(
"the detail contains {hazard}; a `## History` entry is one line, so a detail carrying \
one would render as more than one entry and a reader would take the extra line for an \
event nobody wrote"
)));
}
let timestamp = timestamp.trim();
if !validate_timestamp(timestamp) {
return Err(TixError::InvalidInput(format!(
"`{timestamp}` is not a `YYYY-MM-DDTHH:MM:SSZ` instant; an audit time is evidence and \
is never invented"
)));
}
let store = CycleStore::new(root);
let Some(stored) = stored_cycle_by_id(&store, cycle_id)? else {
return Err(TixError::NotFound(format!(
"generated cycle `{cycle_id}` has no record in `docs/cycles/`, so there is nothing to \
append an event to"
)));
};
let (mut history, rest) = History::parse_front(&stored.body);
let already = history
.events()
.last()
.is_some_and(|event| event.kind == kind && event.detail == detail);
if already {
return Ok(false);
}
history.append(HistoryEvent::new(timestamp, actor, kind, detail));
write_cycle(
&store,
&stored.record,
&format!("{}{rest}", history.render()),
)?;
Ok(true)
}
fn stored_cycle_by_id(
store: &CycleStore,
cycle_id: &GeneratedCycleId,
) -> Result<Option<StoredCycle>> {
for file in RecordStore::new(store.dir()).list::<CycleRecord>()? {
if let Ok(Some(cycle)) = GeneratedCycle::from_record(&file.value)
&& cycle.id == *cycle_id
{
return Ok(Some(StoredCycle {
record: file.value,
body: file.body,
}));
}
}
Ok(None)
}
fn is_cycle_history_kind(kind: HistoryEventKind) -> bool {
matches!(
kind,
HistoryEventKind::CycleClosed
| HistoryEventKind::CycleReopened
| HistoryEventKind::CycleExtended
| HistoryEventKind::CycleSkipped
| HistoryEventKind::CyclePaused
| HistoryEventKind::CycleResumed
| HistoryEventKind::CycleAdjusted
| HistoryEventKind::CycleCapacityEdited
)
}
fn roll_back(store: &CycleStore, written: &[(String, Option<StoredCycle>)]) -> Vec<String> {
let mut failures = Vec::new();
for (name, original) in written.iter().rev() {
match original {
Some(stored) => {
if let Err(error) = store.save_record(&stored.record, Some(&stored.body)) {
failures.push(format!("restoring `{name}` failed: {error}"));
}
}
None => {
if let Err(error) = store.delete(name) {
failures.push(format!("removing `{name}` failed: {error}"));
}
}
}
}
failures
}
fn roll_back_tail(undone: &[String], restored: usize) -> String {
if undone.is_empty() {
format!("the {restored} record(s) already written were restored")
} else {
format!(
"restoring the records already written also failed ({}), so a re-run may be needed",
undone.join("; ")
)
}
}
fn missing_ancestors(dir: &Path) -> Vec<PathBuf> {
let mut missing = Vec::new();
let mut current = Some(dir.to_path_buf());
while let Some(path) = current {
if path.exists() {
break;
}
missing.push(path.clone());
current = path.parent().map(Path::to_path_buf);
}
missing
}
fn remove_empty_dirs(dirs: &[PathBuf]) {
for dir in dirs {
let empty = std::fs::read_dir(dir)
.map(|mut entries| entries.next().is_none())
.unwrap_or(false);
if empty {
let _ = std::fs::remove_dir(dir);
}
}
}
fn refused(
action: LifecycleAction,
schedule_id: CycleScheduleId,
cycle_id: Option<GeneratedCycleId>,
kind: LifecycleDiagnosticKind,
field: impl Into<String>,
message: impl Into<String>,
) -> LifecycleOutcome {
LifecycleOutcome::Rejected {
action,
schedule_id,
cycle_id,
diagnostics: vec![LifecycleDiagnostic::new(kind, field, message)],
}
}
fn no_op(
action: LifecycleAction,
schedule_id: CycleScheduleId,
cycle_id: Option<GeneratedCycleId>,
message: impl Into<String>,
) -> LifecycleOutcome {
LifecycleOutcome::NoOp {
action,
schedule_id,
cycle_id,
message: message.into(),
}
}
fn missing_cycle(
action: LifecycleAction,
schedule_id: CycleScheduleId,
verb: &str,
) -> LifecycleOutcome {
refused(
action,
schedule_id.clone(),
None,
LifecycleDiagnosticKind::MissingCycle,
format!("cycle_schedules.{schedule_id}.cycle_id"),
format!("{verb} names the instance to act on; none was given"),
)
}
fn not_owned(
action: LifecycleAction,
schedule_id: CycleScheduleId,
cycle_id: GeneratedCycleId,
) -> LifecycleOutcome {
let field = format!("cycles.{cycle_id}");
let message = format!(
"cycle id `{cycle_id}` belongs to schedule `{}`, not `{schedule_id}`",
cycle_id.schedule_id()
);
refused(
action,
schedule_id,
Some(cycle_id),
LifecycleDiagnosticKind::CycleNotOwned,
field,
message,
)
}
fn unknown_cycle(
action: LifecycleAction,
schedule_id: CycleScheduleId,
cycle_id: GeneratedCycleId,
) -> LifecycleOutcome {
refused(
action,
schedule_id,
Some(cycle_id.clone()),
LifecycleDiagnosticKind::UnknownCycle,
format!("cycles.{cycle_id}"),
format!("generated cycle `{cycle_id}` does not exist"),
)
}
fn missing_cadence(
action: LifecycleAction,
schedule_id: CycleScheduleId,
cycle_id: Option<GeneratedCycleId>,
) -> LifecycleOutcome {
refused(
action,
schedule_id.clone(),
cycle_id,
LifecycleDiagnosticKind::MissingCadence,
format!("cycle_schedules.{schedule_id}.cadence.kind"),
format!("schedule `{schedule_id}` names no cadence family"),
)
}
fn invalid_cadence(
action: LifecycleAction,
schedule_id: CycleScheduleId,
cycle_id: Option<GeneratedCycleId>,
error: &crate::recurrence::rule::RuleError,
request: &LifecycleRequest,
) -> LifecycleOutcome {
refused(
action,
schedule_id,
cycle_id,
LifecycleDiagnosticKind::InvalidCadence,
format!("cycle_schedules.{}.cadence", request.schedule_id),
error.first().message.clone(),
)
}
fn unresolvable(
action: LifecycleAction,
schedule_id: CycleScheduleId,
cycle_id: Option<GeneratedCycleId>,
) -> LifecycleOutcome {
refused(
action,
schedule_id.clone(),
cycle_id,
LifecycleDiagnosticKind::UnresolvableDate,
format!("cycle_schedules.{schedule_id}.cadence"),
format!(
"schedule `{schedule_id}` could not resolve a candidate date for the sequence through \
its cadence"
),
)
}
fn refused_rollback(
action: LifecycleAction,
schedule_id: CycleScheduleId,
cycle_id: Option<GeneratedCycleId>,
config_path: &str,
error: &TixError,
rollback_failure: Option<String>,
) -> LifecycleOutcome {
let message = match rollback_failure {
None => format!(
"the configuration could not be persisted through `{config_path}`: {error}; the record \
writes were rolled back, so the project is exactly as it was"
),
Some(failure) => format!(
"the configuration could not be persisted through `{config_path}`: {error}; rolling \
back a record also failed ({failure}), so a re-run may be needed"
),
};
refused(
action,
schedule_id,
cycle_id,
LifecycleDiagnosticKind::ConfigNotPersisted,
config_path,
message,
)
}
fn nth_occurrence(rule: &RecurrenceRule, sequence: u32) -> Option<String> {
let mut date = rule.anchor_key().ok()?.as_str().to_string();
for _ in 1..sequence {
date = step_from(rule, &date)?;
}
Some(date)
}
fn step_from(rule: &RecurrenceRule, from: &str) -> Option<String> {
let date = as_date(from)?;
rule.next_occurrence(&RuleContext::on(date))
.ok()
.map(|key| key.as_str().to_string())
}
fn nominal_due(
schedule_id: &CycleScheduleId,
record: &CycleScheduleConfiguration,
start_date: &str,
) -> std::result::Result<String, LifecycleDiagnostic> {
let field = format!("cycle_schedules.{schedule_id}.duration");
let Some(unit) = record.duration.unit else {
return Err(LifecycleDiagnostic::new(
LifecycleDiagnosticKind::InvalidDuration,
format!("{field}.unit"),
"duration.unit: how long one cycle lasts is not configured; give `days`, `weeks` or \
`months`",
));
};
if record.duration.count == 0 {
return Err(LifecycleDiagnostic::new(
LifecycleDiagnosticKind::InvalidDuration,
format!("{field}.count"),
"duration.count: a cycle must last at least one unit",
));
}
let Some(start) = as_date(start_date) else {
return Err(LifecycleDiagnostic::new(
LifecycleDiagnosticKind::UnresolvableDate,
format!("{field}.count"),
format!(
"the instance's start date `{start_date}` is not a `YYYY-MM-DD` calendar date, so \
its duration cannot be added"
),
));
};
let rule = RecurrenceRule {
kind: RuleKind::Interval,
interval: Some(record.duration.count),
anchor: Some(start_date.to_string()),
timezone: record.timezone.clone(),
expression: None,
unit: Some(unit),
extensions: BTreeMap::new(),
};
rule.next_occurrence(&RuleContext::on(start))
.map(|due| due.as_str().to_string())
.map_err(|error| {
let first = error.first();
LifecycleDiagnostic::new(
LifecycleDiagnosticKind::InvalidDuration,
format!("{field}.count"),
first.message.clone(),
)
})
}
fn reference_name(schedule_name: &str, start_date: &str, sequence: u32) -> String {
let component: String = schedule_name
.trim()
.chars()
.take(NAME_COMPONENT_BUDGET)
.collect();
let component = component.trim_end();
format!(
"{component} {start_date} {sequence:0width$}",
width = GeneratedCycleId::SEQUENCE_WIDTH
)
}
const FILENAME_STEM_LIMIT: usize = 80;
const CANONICAL_DATE_LEN: usize = 10;
const NAME_COMPONENT_BUDGET: usize =
FILENAME_STEM_LIMIT - 1 - CANONICAL_DATE_LEN - 1 - GeneratedCycleId::SEQUENCE_WIDTH;
fn as_date(value: &str) -> Option<Date> {
if !crate::model::validate_date(value) {
return None;
}
let year: i32 = value.get(0..4)?.parse().ok()?;
let month: u8 = value.get(5..7)?.parse().ok()?;
let day: u8 = value.get(8..10)?.parse().ok()?;
Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()
}