tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

/// A cycle record as stored, with its Markdown body.
///
/// The identity adapter ([`GeneratedCycle`]) carries neither, so every write reads
/// this first and merges the change onto it — that is what preserves a description,
/// a hand-written body and any previously appended `## History` entry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct StoredCycle {
    pub(super) record: CycleRecord,
    pub(super) body: String,
}

/// Read the stored record and body for `name`, when a file holds one.
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,
        })
}

/// The stored body, or empty when there is no stored record.
pub(super) fn stored_body(stored: Option<&StoredCycle>) -> &str {
    stored.map_or("", |stored| stored.body.as_str())
}

/// The record to write for `cycle`, merging onto the stored record when one exists.
///
/// Description and any other carried field come from the stored record; the identity
/// fields (name, schedule, sequence, state, dates) always come from the generated
/// cycle, because they are the action's subject. Callers override the one field their
/// action changes afterwards.
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
}

/// Append one rendered `## History` entry to `body`, preserving everything already
/// there (including unparseable lines, via [`History::parse_front`]).
///
/// **The `detail` and the request's actor are validated before they reach here.**
/// [`HistoryEvent::render`] writes each field raw, so text carrying a line break would
/// render as more than one entry and a reader would take the extra line for an event
/// nobody wrote. The guarantee therefore lives at the one place an event can enter a
/// cycle's log — `apply`'s audit gate — rather than here, where it could only be
/// asserted: this function has no way to refuse.
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())
}

/// Write a cycle record with its body — the one write path every action uses.
pub(super) fn write_cycle(store: &CycleStore, record: &CycleRecord, body: &str) -> Result<()> {
    store.save_record(record, Some(body))
}

/// The characters a rendered `## History` entry cannot hold, or `None` when the text is
/// safe (WP04, `cycle-capacity-planning-01M3C85C`).
///
/// # Why this is here rather than in a builder
///
/// A [`HistoryEvent`] is **one line** whose four fields are separated by U+2014, and
/// [`HistoryEvent::render`] writes each field raw. So:
///
/// * text carrying a line break renders as **two** lines, and the second parses as an
///   event nobody wrote — a reason of `ok\n- <instant> — mallory — cycle_closed — …`
///   would make one `apply` call produce two entries, the second attributed to
///   `mallory`. That is the worst kind of corruption, because the log is the thing an
///   auditor trusts;
/// * text carrying U+2014 shifts [`HistoryEvent::parse`]'s separator split, so the entry
///   stops parsing as the kind it is.
///
/// Both are refused wherever an event can enter a cycle record's log — [`apply`]'s audit
/// gate, which covers `close`, `reopen` and every other writing action, including the
/// `reason:` detail `close` has always written. The builders in
/// [`crate::capacity::snapshot`] deliberately do **not** repeat this policy: one place
/// owns it, and this is it.
///
/// `check_separator` says whether the text is written in a **detail** position, where
/// U+2014 is refused too. The actor is written *between* two separators: an em dash in it
/// costs the entry its parse — which is already true of every issue event in this codebase,
/// and is a loss rather than a forgery — and an identity may legitimately contain one, so
/// refusing it would be a behaviour change nobody asked for. A line break forges an entry
/// in **every** field, so every field refuses one.
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
    }
}

/// Undo the record writes this call made: restore each original, or remove a file
/// that had no original.
///
/// Returns **what could not be undone**. A rollback that fails is the one case where a
/// re-run may be needed, and swallowing it is what let a refusal say "the project is
/// exactly as it was" while a record still held the rolled-forward state.
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
}

/// How a refusal describes what a rollback did — and what it did not.
///
/// `restored` is how many records the call had written. An empty `undone` means the
/// tree really is back where it started; anything else names what is still wrong,
/// because that is the useful fact and "everything was restored" is a claim the code
/// cannot always make.
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("; ")
        )
    }
}

/// The directories below `dir` that do not exist yet, deepest first.
///
/// A write creates its directory chain on demand, so a rolled-back capture has to
/// remove the chain it created as well as the file in it, or it would leave a (now
/// empty) `docs/cycle_snapshots/` behind that changes what a later write sees.
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
}

/// Remove the directories a rolled-back write created, when they are still empty.
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);
        }
    }
}

/// The `sequence`-th occurrence of `rule`'s cadence, counted from its own anchor.
///
/// `1` is the anchor itself; each further occurrence is one [`RecurrenceRule`] step
/// (never hand-written arithmetic), so month-end re-anchoring and leap days are the
/// shared owner's. `None` when a step cannot be resolved.
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)
}

/// One cadence step after `from`, through the shared arithmetic owner.
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())
}

/// `start_date + duration`, through the shared arithmetic owner.
///
/// The duration is expressed as the rule the shared owner already understands — a
/// step of `count` `unit`s anchored at the instance's own start — so its first
/// occurrence strictly after that anchor *is* the due date. This mirrors WP04's
/// `duration` computation so a lifecycle-adjusted window and a generated one agree.
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(),
            )
        })
}

/// The reference label a not-yet-generated skip placeholder takes.
///
/// Mirrors WP04's label exactly (`"<name> <date> <sequence>"`, with the name component
/// truncated to leave the date and the padded sequence intact), so a placeholder and
/// a later generation of the same sequence would agree on the label. The identity is
/// never the label, but they must not disagree.
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
    )
}

/// `slugify`'s cap on a filename stem (mirrored from WP04).
pub(super) const FILENAME_STEM_LIMIT: usize = 80;

/// The length of a canonical `YYYY-MM-DD` date.
pub(super) const CANONICAL_DATE_LEN: usize = 10;

/// How much of a schedule's display name a reference label keeps (mirrored from WP04).
pub(super) const NAME_COMPONENT_BUDGET: usize =
    FILENAME_STEM_LIMIT - 1 - CANONICAL_DATE_LEN - 1 - GeneratedCycleId::SEQUENCE_WIDTH;

/// A canonical `YYYY-MM-DD` date as the shared arithmetic owner's value.
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()
}