tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
#[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 two places an event can enter a
/// cycle's log — `apply`'s audit gate and [`append_cycle_event`] — 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) and [`append_cycle_event`] (the seam a
/// surface outside this module uses) — so the guarantee cannot be bypassed by reaching
/// for one entry point rather than the other. 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
    }
}

/// Append **one** `## History` entry to a generated cycle record's own body, and
/// nothing else (mission `cycle-capacity-planning-01M3C85C`, WP04/T021).
///
/// This is the narrow seam a closed-cycle change outside the lifecycle needs — a
/// capacity edit applied through `tix capacity`/`tix cycle reopen` — so that the
/// *event's* construction stays where it is tested
/// ([`crate::capacity::snapshot::capacity_edit_event`],
/// [`crate::capacity::snapshot::reopen_event`]) and the *write* stays here, beside the
/// one `append_event` grammar every other cycle event uses.
///
/// # The guarantee, in the signature
///
/// **One applied change appends exactly one entry, and the cycle's snapshot is never
/// rewritten.** Concretely:
///
/// * the entry is appended through [`History::parse_front`] + [`History::append`] +
///   [`History::render`], so every existing line — the `cycle_closed` entry, a
///   hand-edited body, an unparseable line — keeps its position and its bytes;
/// * if the identical decision is **already the log's last entry**, the call is a
///   no-op: it returns `Ok(false)` and writes *nothing at all* (not even a re-render),
///   so re-applying the same edit twice does not double the entry. That is the
///   "repeated action, whose postcondition already holds" rule this module applies
///   everywhere, and it is sound because a genuinely different before/after or reason
///   produces a different `detail`;
/// * the snapshot is not read, moved or written by this function — a caller can only
///   lose evidence by writing the snapshot file itself, which nothing here does;
/// * a blank actor or an off-shape timestamp is refused, because the entry is audit
///   evidence and this codebase never invents an actor or a clock;
/// * an actor carrying a line break, and a `detail` carrying a line break **or** the
///   U+2014 the entry separates its fields with, are refused by the same
///   [`history_text_hazard`] `apply`'s audit gate runs — so calling this seam directly
///   cannot smuggle a second, attacker-chosen entry into the log.
///
/// `kind` must be one of the cycle lifecycle's own kinds; an issue-vocabulary kind is
/// refused, because this writes a cycle record's log and a mislabel would make the
/// entry unreadable to the readers of that log.
///
/// `Ok(true)` means an entry was appended. `Err` is a genuine I/O or validation failure;
/// a store error is returned rather than reported as a decision, matching
/// [`apply`]'s own split.
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(),
        ));
    }
    // The same validator `apply` runs, so the single-line guarantee is enforced on both
    // entrances to a cycle's log and cannot be bypassed by calling this seam directly.
    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 {
        // The identical decision is already the log's last entry: writing anything —
        // even a re-render — would be a change the caller did not make.
        return Ok(false);
    }
    history.append(HistoryEvent::new(timestamp, actor, kind, detail));
    // The record's frontmatter and its body (description, hand-written prose, every
    // prior event) are carried forward unchanged; only the one line is added.
    write_cycle(
        &store,
        &stored.record,
        &format!("{}{rest}", history.render()),
    )?;
    Ok(true)
}

/// The stored record and body of a generated cycle, looked up by its **stable id**.
///
/// The same identity rule the rest of the boundary follows: the id is matched against
/// each record's parsed generated identity (never against a display name), so a rename
/// of the label cannot make this append to the wrong cycle's log. A record whose
/// generated identity is half written is skipped rather than guessed at — it cannot be
/// the cycle `cycle_id` names, and rewriting it would be a migration.
pub(super) 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)
}

/// Whether a `## History` kind belongs to a **cycle record's** log.
///
/// The cycle lifecycle's kinds and nothing else: an issue's vocabulary written into a
/// cycle record would be a log no reader of either side expects.
pub(super) 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
    )
}

/// 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()
}