#[allow(clippy::wildcard_imports)]
use super::*;
#[allow(clippy::too_many_lines)]
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)),
}
}
#[allow(clippy::too_many_lines)]
pub(super) 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,
})
}
pub(super) 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,
})
}
#[allow(clippy::too_many_lines)]
pub(super) 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",
})
}
pub(super) 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"),
})
}
pub(super) 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()
),
})
}
#[allow(clippy::too_many_lines)]
pub(super) 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()
),
})
}
pub(super) 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",
)
}