tix-tracker 0.12.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
//! Archive delegation (WP05).
//!
//! #190 owns archiving. This mission owns asking for it.
//!
//! That boundary is the whole design, and it is stated three times in the
//! contracts because getting it wrong is expensive:
//!
//! - `contracts/operation-outcomes.md`: "Archive operations use the issue-190
//!   outcome contract; bulk does not define a second archive state or event."
//! - `contracts/patch-plan.md` §6: "An archive patch is a typed request to the
//!   issue-190 archive service; it has no bulk-only archive semantics."
//! - C-005 in #190's own contract: archiving "does **not** move, rename, merge, or
//!   remove the file".
//!
//! So this module does four things and refuses one.
//!
//! ## What it does
//!
//! 1. Carries a **request**: which direction, which issues, and — for a restore —
//!    the **explicit target status** #190 requires. A restore without one is refused
//!    *here*, before the user confirms anything, because the service will refuse it
//!    and a preview that hides that is a preview that lies.
//! 2. Maps the service's outcomes into the shared batch outcome vector, so there is
//!    one outcome type rather than two (R7 in #190's contract says the same).
//! 3. Reports the **History event** the apply appends: `archived` (or, for the
//!    unwired restore direction, `restored`), the one kind `Store::set_archived`
//!    writes.
//! 4. Applies the archive direction by **delegating** to the store's own archive
//!    service — [`Store::archive`], which renames the file in place and appends the
//!    History event — one call per selected issue, with the selector the plan
//!    carries, wrapped in a per-item `Store::transaction` (#1580).
//!
//! ## What it refuses
//!
//! The **restore** direction. `ArchiveRequest::restore` has no caller — the CLI has
//! no `bulk restore` verb and the TUI's archive action is archive-only — and the
//! restore direction's explicit-target-status contract (#190's C-003) is a separate
//! question this build does not answer. So [`apply_archive`] refuses a restore, and
//! a restore without an explicit target status is refused first and unchanged
//! ([`ArchiveProblem::MissingTargetStatus`]).
//!
//! Archiving is **not** implemented here. There is exactly one archive
//! implementation, and it is the store's ([`Store::archive`] → `set_archived`),
//! which renames the file in place and appends one History event. A second one in
//! this module would be the implementation the contract prohibits — and the one with
//! no tests behind it.

use std::collections::BTreeMap;

use crate::bulk::apply::{ApplyReport, ItemOutcome};
use crate::bulk::plan::BatchPlan;
use crate::bulk::{ArchiveRequest, IssuePatch, Selection};
use crate::error::{Result, TixError};
use crate::storage::Store;

/// The mission that owns archiving, named in the refusal so a user can find it.
///
/// A constant rather than a string in the message, because a refusal that says
/// "not available" and a comment that says "see #190" are two facts a reader has to
/// join by hand. The message carries the name.
pub const ARCHIVE_OWNER: &str = "issue-archive-restore-01M3C4JF";

/// One issue's request to the archive service.
///
/// Issue identity is carried as **both** the number and the selector the user typed,
/// because the service takes selectors (`bulk_archive` is "issue selectors") and a
/// bulk preview has numbers. Resolving a number back to a selector would be a
/// second resolution path, and the selector is what the user asked for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveItemRequest {
    /// The issue number, stable across an archive and a restore (C-002 in #190).
    pub number: u32,
    /// The selector as typed, or the number's own spelling when the source was a
    /// query rather than explicit selectors.
    pub selector: String,
    /// The direction, as the service names it.
    pub operation: ArchiveOperation,
    /// The explicit target status, for a restore.
    ///
    /// #190's C-003: "Restore requires an explicit `target_status`. A restore
    /// without one is rejected; there is no 'restore to the previous status'
    /// behavior." So this is not optional on a restore, and [`ArchiveRequest`]
    /// carries it for the same reason — see its own doc comment.
    pub target_status: Option<String>,
    /// The reason the user supplied, preserved verbatim.
    ///
    /// Not interpreted here. A reason is text the service records, and a bulk side
    /// that summarised it would be authoring a second version of the record.
    pub reason: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchiveOperation {
    Archive,
    Restore,
}

impl ArchiveOperation {
    /// The service's own verb, so a diagnostic names `bulk_archive` or
    /// `bulk_restore` rather than "an archive operation".
    pub fn service_fn(self) -> &'static str {
        match self {
            Self::Archive => "bulk_archive",
            Self::Restore => "bulk_restore",
        }
    }

    pub fn as_str(self) -> &'static str {
        match self {
            Self::Archive => "archive",
            Self::Restore => "restore",
        }
    }
}

/// The whole batch's request, in the shape #190's `bulk_archive` takes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveBatchRequest {
    pub operation: ArchiveOperation,
    /// One entry per selected issue, in the plan's order. #190's bulk rules require
    /// "exactly one outcome per selected issue", so the request is one-per-issue and
    /// the order is the plan's.
    pub items: Vec<ArchiveItemRequest>,
}

impl ArchiveBatchRequest {
    pub fn len(&self) -> usize {
        self.items.len()
    }

    pub fn is_empty(&self) -> bool {
        self.items.is_empty()
    }

    /// The one-line summary, with the count named even when zero.
    pub fn summary(&self) -> String {
        format!(
            "{} {} request for {} issue{}",
            self.operation.service_fn(),
            self.operation.as_str(),
            self.items.len(),
            if self.items.len() == 1 { "" } else { "s" }
        )
    }
}

/// Why an archive request cannot even be built.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchiveProblem {
    /// A restore with no target status. #190's C-003.
    MissingTargetStatus,
    /// The selection carried an issue the service could not be asked about —
    /// unreachable through `Selection`, so this exists for a caller that builds a
    /// request by hand.
    UnknownIssue,
}

impl ArchiveProblem {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::MissingTargetStatus => "missing_target_status",
            Self::UnknownIssue => "unknown_issue",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveDiagnostic {
    pub problem: ArchiveProblem,
    /// The issue, when the problem belongs to one.
    pub issue: Option<u32>,
    pub message: String,
}

impl std::fmt::Display for ArchiveDiagnostic {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self.issue {
            Some(issue) => write!(f, "#{}: {}: {}", issue, self.problem.as_str(), self.message),
            None => write!(f, "{}: {}", self.problem.as_str(), self.message),
        }
    }
}

/// Build the service request from a confirmed plan's patch.
///
/// **Pure**: reads the patch and the plan's items, writes nothing, and needs no
/// store — so a preview can be rendered with no filesystem at all, which is the
/// same reason [`crate::bulk::plan::build_plan`] takes an `issues_dir` rather than a
/// `Store`.
///
/// The refusal is returned rather than raised, because a plan must be *reportable*:
/// the preview of a `tix bulk archive` on a restore with no target status should print
/// the diagnostic and exit non-zero, not panic, and the outcome vector is where that
/// diagnostic belongs.
pub fn adapt(
    patch: &IssuePatch,
    numbers: &[u32],
    selectors: &BTreeMap<u32, String>,
) -> std::result::Result<ArchiveBatchRequest, ArchiveDiagnostic> {
    let request = patch.archive.clone().ok_or_else(|| ArchiveDiagnostic {
        problem: ArchiveProblem::UnknownIssue,
        issue: None,
        message: "this patch carries no archive request".to_string(),
    })?;
    Ok(build(request, numbers, selectors))
}

/// The build half of [`adapt`], taking the direction and target directly.
///
/// Separate so the refusal lives in one place and the construction is total: a
/// caller that has already validated can build without re-deciding, and a caller
/// that has not can go through [`adapt`].
fn build(
    request: ArchiveRequest,
    numbers: &[u32],
    selectors: &BTreeMap<u32, String>,
) -> ArchiveBatchRequest {
    let operation = if request.archive {
        ArchiveOperation::Archive
    } else {
        ArchiveOperation::Restore
    };
    // A restore with no target status is refused **before** the items are built, so
    // a plan built from such a patch has no items to report a per-item problem on
    // — the problem is with the patch, not with an issue.
    let target_status = if request.archive {
        None
    } else {
        request.target_status.clone()
    };
    let items = numbers
        .iter()
        .map(|number| ArchiveItemRequest {
            number: *number,
            selector: selectors
                .get(number)
                .cloned()
                .unwrap_or_else(|| number.to_string()),
            operation,
            target_status: target_status.clone(),
            reason: request.reason.clone(),
        })
        .collect();
    ArchiveBatchRequest { operation, items }
}

/// The refusal a request carries, if any. Called by the preview and by the apply, so
/// the two cannot disagree about whether a request is well-formed.
pub fn refusal(request: &ArchiveBatchRequest) -> Option<ArchiveDiagnostic> {
    if request.operation != ArchiveOperation::Restore {
        return None;
    }
    if request
        .items
        .iter()
        .all(|item| item.target_status.is_some())
    {
        return None;
    }
    Some(ArchiveDiagnostic {
        problem: ArchiveProblem::MissingTargetStatus,
        issue: None,
        message: "restoring needs an explicit target status — there is no \\
                  \"restore to the previous status\", so name the column to restore \\
                  into"
            .to_string(),
    })
}

/// How many History entries applying this patch would append.
///
/// **One**, because `Store::set_archived` appends exactly one event per applied
/// archive: `HistoryEventKind::Archived` (or `Restored` for the restore direction),
/// the operation the file's new name records. The earlier version predicted zero and
/// justified it by `HistoryEventKind` having no archive variant — true when the
/// module was written, and false now that the model carries `Archived` and
/// `Restored`. The single-issue path is the reference: `tix archive` writes that one
/// entry, so a bulk archive that predicted none would disagree with it.
///
/// A patch with no archive request appends nothing.
pub fn predicted_history_events(patch: &IssuePatch) -> usize {
    usize::from(patch.archive.is_some())
}

/// The History kind an archive apply appends, or `None` when the patch carries no
/// archive request.
///
/// `Some("archived")` for an archive and `Some("restored")` for a restore — the
/// model's own spellings [`crate::model::HistoryEventKind::as_str`] produces, not a
/// bulk-only vocabulary word. Read from the request's direction, because a patch is
/// one or the other and never both.
pub fn history_kind_for(patch: &IssuePatch) -> Option<&'static str> {
    patch.archive.as_ref().map(|request| {
        if request.archive {
            "archived"
        } else {
            "restored"
        }
    })
}

/// Apply a confirmed archive plan.
///
/// Per selected issue: ask the store's archive service for the archive, wrapped in
/// its own `Store::transaction` (#1580), and map the outcome into the shared batch
/// vocabulary. The store does the archiving — this function only asks it — so the
/// one archive implementation stays the store's.
///
/// The three outcomes are the shared ones:
///
/// - [`ItemOutcome::Applied`] — the store archived the issue; the file is at its new
///   dot-prefixed name.
/// - [`ItemOutcome::NoOp`] — the issue was already archived. `Store::set_archived`
///   refuses a redundant call by name (`already_archived`), and a batch re-run must
///   report that rather than fail, so it is a no-op and nothing is written.
/// - [`ItemOutcome::Rejected`] — any other store refusal (an item that no longer
///   resolves, a filename that cannot carry the marker). Nothing is rolled back:
///   `contracts/operation-outcomes.md` says a confirmation authorizes independent
///   item processing, not an all-or-nothing transaction.
///
/// The **restore** direction is refused before any write, and a restore without an
/// explicit target status is refused first, unchanged — see the module doc.
pub fn apply_archive(store: &Store, plan: &BatchPlan) -> Result<ApplyReport> {
    // The confirmation gate first, for the same reason WP03's and WP04's are: the
    // gate belongs to the apply, and a caller that reached the write path without
    // one should learn that before the store is read.
    if plan.confirmation.is_none() || !plan.confirmation_is_valid() {
        return Err(TixError::InvalidInput(
            "a bulk apply needs a confirmation for exactly this plan — preview it \
             first, and preview again if the selection changed"
                .to_string(),
        ));
    }
    let request = plan
        .patch
        .archive
        .clone()
        .ok_or_else(|| TixError::InvalidInput("this plan carries no archive request".into()))?;

    if !request.archive {
        // A malformed restore is refused **before** the direction itself, because it
        // would be refused anyway and the user can fix it now; #190's C-003 says a
        // restore without an explicit target status is rejected.
        if request.target_status.is_none() {
            return Err(TixError::InvalidInput(format!(
                "{}: restoring needs an explicit target status — there is no \\
                 \"restore to the previous status\"",
                ArchiveProblem::MissingTargetStatus.as_str()
            )));
        }
        // The restore direction is not wired in this build. It has no caller, and its
        // target-status contract is a separate question (the module doc), so it keeps
        // a named refusal rather than being performed by a path nobody asked for.
        return Err(TixError::InvalidInput(format!(
            "archive delegation is not available for a restore: this build wires \
             `bulk archive` to the store's archive service, but the bulk restore \
             direction is not wired — mission {ARCHIVE_OWNER} owns the target-status \
             contract, so this will delegate to its `bulk_restore` once that lands"
        )));
    }

    // The per-item requests, built through the module's own adapter so each item is
    // asked for with the **selector the user typed** rather than a number resolved
    // back to a selector. The selection's selectors are zipped with its issues — the
    // explicit ones, in the order they resolved — and `adapt` falls back to a
    // number's own spelling for an item that came from a query.
    let batch = adapt(
        &plan.patch,
        &plan.selection.numbers(),
        &selector_map(&plan.selection),
    )
    .map_err(|diagnostic| TixError::InvalidInput(diagnostic.to_string()))?;

    let mut outcomes = Vec::with_capacity(batch.len());
    for item in &batch.items {
        // One transaction per item (#1580): `Store::archive` renames the file and
        // *then* appends the History event, and a failure between the two must put
        // the file back at its active name rather than leave it archived without its
        // audit entry. A per-item scope, never one over the whole batch — a failure
        // on one issue must not roll a sibling back.
        let result = store.transaction(|| store.archive(&item.selector));
        match result {
            Ok(issue) => outcomes.push((
                item.number,
                ItemOutcome::Applied {
                    filename: issue.filename.clone(),
                },
            )),
            // A redundant call is a **no-op**, not a failure: re-running a batch must
            // report it, and `Store::set_archived` names it `already_archived`.
            Err(error) if already_archived(&error) => {
                outcomes.push((item.number, ItemOutcome::NoOp))
            }
            // Anything else is the store's judgement, and it has touched nothing.
            Err(error) => outcomes.push((item.number, ItemOutcome::Rejected(error.to_string()))),
        }
    }

    Ok(ApplyReport {
        outcomes,
        write_failures: Vec::new(),
    })
}

/// The selectors the plan's issues were resolved from, keyed by number.
///
/// The selection's `selectors` are the ones the user typed, in the order they
/// resolved, so zipping them with the issues attaches each selector to its issue. A
/// query-added issue has no typed selector and is absent from the map; `adapt` falls
/// back to the number's own spelling for it.
fn selector_map(selection: &Selection) -> BTreeMap<u32, String> {
    selection
        .selectors()
        .iter()
        .zip(selection.issues())
        .map(|(selector, issue)| (issue.number, selector.clone()))
        .collect()
}

/// Whether the store refused because the issue is already archived.
///
/// `Store::set_archived` names the diagnostic (`already_archived:`) in an
/// `InvalidInput` message — which renders with the variant's own prefix — rather than
/// returning a typed outcome, so this reads the store's word from the rendered error.
/// It is the one refusal a batch must treat as success-shaped: a re-run is idempotent
/// and reports the no-op.
fn already_archived(error: &TixError) -> bool {
    error.to_string().contains("already_archived")
}

/// Whether this patch can be applied by [`apply_archive`]: true for an **archive**
/// request, false for a restore (which this build deliberately does not wire).
pub fn can_apply(patch: &IssuePatch) -> bool {
    patch
        .archive
        .as_ref()
        .is_some_and(|request| request.archive)
}

// The semantic audit — what #190's contract forbids a bulk archive from doing —
// lives in `tests/bulk_archive.rs`, **not** here.
//
// It was in this file first, as `semantic_audit()` returning the rules as data, so a
// caller would get the reasoning and the greps together. That does not work: the
// rules are string patterns, so a rule list that lives in the file it audits
// **contains its own patterns** and the grep fires on the audit. The case reported
// `fs::rename` as a violation of C-005, having found it in the audit's own row for
// C-005.
//
// The rules are test data, so they are in the test, next to the greps that enforce
// them and the reasoning a reviewer needs to disagree with one. This module carries
// only what production needs: the owning mission's name and the refusals.