#[allow(clippy::wildcard_imports)]
use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct StoredCycle {
pub(super) record: CycleRecord,
pub(super) body: String,
}
pub(super) 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,
})
}
pub(super) fn stored_body(stored: Option<&StoredCycle>) -> &str {
stored.map_or("", |stored| stored.body.as_str())
}
pub(super) 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
}
pub(super) 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())
}
pub(super) fn write_cycle(store: &CycleStore, record: &CycleRecord, body: &str) -> Result<()> {
store.save_record(record, Some(body))
}
pub(super) 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(super) 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
}
pub(super) 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("; ")
)
}
}
pub(super) 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
}
pub(super) 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);
}
}
}
pub(super) 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)
}
pub(super) 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())
}
pub(super) 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(),
)
})
}
pub(super) 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
)
}
pub(super) const FILENAME_STEM_LIMIT: usize = 80;
pub(super) const CANONICAL_DATE_LEN: usize = 10;
pub(super) const NAME_COMPONENT_BUDGET: usize =
FILENAME_STEM_LIMIT - 1 - CANONICAL_DATE_LEN - 1 - GeneratedCycleId::SEQUENCE_WIDTH;
pub(super) 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()
}