Skip to main content

tix/bulk/
archive.rs

1//! Archive delegation (WP05).
2//!
3//! #190 owns archiving. This mission owns asking for it.
4//!
5//! That boundary is the whole design, and it is stated three times in the
6//! contracts because getting it wrong is expensive:
7//!
8//! - `contracts/operation-outcomes.md`: "Archive operations use the issue-190
9//!   outcome contract; bulk does not define a second archive state or event."
10//! - `contracts/patch-plan.md` §6: "An archive patch is a typed request to the
11//!   issue-190 archive service; it has no bulk-only archive semantics."
12//! - C-005 in #190's own contract: archiving "does **not** move, rename, merge, or
13//!   remove the file".
14//!
15//! So this module does four things and refuses one.
16//!
17//! ## What it does
18//!
19//! 1. Carries a **request**: which direction, which issues, and — for a restore —
20//!    the **explicit target status** #190 requires. A restore without one is refused
21//!    *here*, before the user confirms anything, because the service will refuse it
22//!    and a preview that hides that is a preview that lies.
23//! 2. Maps the service's outcomes into the shared batch outcome vector, so there is
24//!    one outcome type rather than two (R7 in #190's contract says the same).
25//! 3. Reports the **History event** the apply appends: `archived` (or, for the
26//!    unwired restore direction, `restored`), the one kind `Store::set_archived`
27//!    writes.
28//! 4. Applies the archive direction by **delegating** to the store's own archive
29//!    service — [`Store::archive`], which renames the file in place and appends the
30//!    History event — one call per selected issue, with the selector the plan
31//!    carries, wrapped in a per-item `Store::transaction` (#1580).
32//!
33//! ## What it refuses
34//!
35//! The **restore** direction. `ArchiveRequest::restore` has no caller — the CLI has
36//! no `bulk restore` verb and the TUI's archive action is archive-only — and the
37//! restore direction's explicit-target-status contract (#190's C-003) is a separate
38//! question this build does not answer. So [`apply_archive`] refuses a restore, and
39//! a restore without an explicit target status is refused first and unchanged
40//! ([`ArchiveProblem::MissingTargetStatus`]).
41//!
42//! Archiving is **not** implemented here. There is exactly one archive
43//! implementation, and it is the store's ([`Store::archive`] → `set_archived`),
44//! which renames the file in place and appends one History event. A second one in
45//! this module would be the implementation the contract prohibits — and the one with
46//! no tests behind it.
47
48use std::collections::BTreeMap;
49
50use crate::bulk::apply::{ApplyReport, ItemOutcome};
51use crate::bulk::plan::BatchPlan;
52use crate::bulk::{ArchiveRequest, IssuePatch, Selection};
53use crate::error::{Result, TixError};
54use crate::storage::Store;
55
56/// The mission that owns archiving, named in the refusal so a user can find it.
57///
58/// A constant rather than a string in the message, because a refusal that says
59/// "not available" and a comment that says "see #190" are two facts a reader has to
60/// join by hand. The message carries the name.
61pub const ARCHIVE_OWNER: &str = "issue-archive-restore-01M3C4JF";
62
63/// One issue's request to the archive service.
64///
65/// Issue identity is carried as **both** the number and the selector the user typed,
66/// because the service takes selectors (`bulk_archive` is "issue selectors") and a
67/// bulk preview has numbers. Resolving a number back to a selector would be a
68/// second resolution path, and the selector is what the user asked for.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ArchiveItemRequest {
71    /// The issue number, stable across an archive and a restore (C-002 in #190).
72    pub number: u32,
73    /// The selector as typed, or the number's own spelling when the source was a
74    /// query rather than explicit selectors.
75    pub selector: String,
76    /// The direction, as the service names it.
77    pub operation: ArchiveOperation,
78    /// The explicit target status, for a restore.
79    ///
80    /// #190's C-003: "Restore requires an explicit `target_status`. A restore
81    /// without one is rejected; there is no 'restore to the previous status'
82    /// behavior." So this is not optional on a restore, and [`ArchiveRequest`]
83    /// carries it for the same reason — see its own doc comment.
84    pub target_status: Option<String>,
85    /// The reason the user supplied, preserved verbatim.
86    ///
87    /// Not interpreted here. A reason is text the service records, and a bulk side
88    /// that summarised it would be authoring a second version of the record.
89    pub reason: Option<String>,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum ArchiveOperation {
94    Archive,
95    Restore,
96}
97
98impl ArchiveOperation {
99    /// The service's own verb, so a diagnostic names `bulk_archive` or
100    /// `bulk_restore` rather than "an archive operation".
101    pub fn service_fn(self) -> &'static str {
102        match self {
103            Self::Archive => "bulk_archive",
104            Self::Restore => "bulk_restore",
105        }
106    }
107
108    pub fn as_str(self) -> &'static str {
109        match self {
110            Self::Archive => "archive",
111            Self::Restore => "restore",
112        }
113    }
114}
115
116/// The whole batch's request, in the shape #190's `bulk_archive` takes.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct ArchiveBatchRequest {
119    pub operation: ArchiveOperation,
120    /// One entry per selected issue, in the plan's order. #190's bulk rules require
121    /// "exactly one outcome per selected issue", so the request is one-per-issue and
122    /// the order is the plan's.
123    pub items: Vec<ArchiveItemRequest>,
124}
125
126impl ArchiveBatchRequest {
127    pub fn len(&self) -> usize {
128        self.items.len()
129    }
130
131    pub fn is_empty(&self) -> bool {
132        self.items.is_empty()
133    }
134
135    /// The one-line summary, with the count named even when zero.
136    pub fn summary(&self) -> String {
137        format!(
138            "{} {} request for {} issue{}",
139            self.operation.service_fn(),
140            self.operation.as_str(),
141            self.items.len(),
142            if self.items.len() == 1 { "" } else { "s" }
143        )
144    }
145}
146
147/// Why an archive request cannot even be built.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum ArchiveProblem {
150    /// A restore with no target status. #190's C-003.
151    MissingTargetStatus,
152    /// The selection carried an issue the service could not be asked about —
153    /// unreachable through `Selection`, so this exists for a caller that builds a
154    /// request by hand.
155    UnknownIssue,
156}
157
158impl ArchiveProblem {
159    pub fn as_str(self) -> &'static str {
160        match self {
161            Self::MissingTargetStatus => "missing_target_status",
162            Self::UnknownIssue => "unknown_issue",
163        }
164    }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct ArchiveDiagnostic {
169    pub problem: ArchiveProblem,
170    /// The issue, when the problem belongs to one.
171    pub issue: Option<u32>,
172    pub message: String,
173}
174
175impl std::fmt::Display for ArchiveDiagnostic {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        match self.issue {
178            Some(issue) => write!(f, "#{}: {}: {}", issue, self.problem.as_str(), self.message),
179            None => write!(f, "{}: {}", self.problem.as_str(), self.message),
180        }
181    }
182}
183
184/// Build the service request from a confirmed plan's patch.
185///
186/// **Pure**: reads the patch and the plan's items, writes nothing, and needs no
187/// store — so a preview can be rendered with no filesystem at all, which is the
188/// same reason [`crate::bulk::plan::build_plan`] takes an `issues_dir` rather than a
189/// `Store`.
190///
191/// The refusal is returned rather than raised, because a plan must be *reportable*:
192/// the preview of a `tix bulk archive` on a restore with no target status should print
193/// the diagnostic and exit non-zero, not panic, and the outcome vector is where that
194/// diagnostic belongs.
195pub fn adapt(
196    patch: &IssuePatch,
197    numbers: &[u32],
198    selectors: &BTreeMap<u32, String>,
199) -> std::result::Result<ArchiveBatchRequest, ArchiveDiagnostic> {
200    let request = patch.archive.clone().ok_or_else(|| ArchiveDiagnostic {
201        problem: ArchiveProblem::UnknownIssue,
202        issue: None,
203        message: "this patch carries no archive request".to_string(),
204    })?;
205    Ok(build(request, numbers, selectors))
206}
207
208/// The build half of [`adapt`], taking the direction and target directly.
209///
210/// Separate so the refusal lives in one place and the construction is total: a
211/// caller that has already validated can build without re-deciding, and a caller
212/// that has not can go through [`adapt`].
213fn build(
214    request: ArchiveRequest,
215    numbers: &[u32],
216    selectors: &BTreeMap<u32, String>,
217) -> ArchiveBatchRequest {
218    let operation = if request.archive {
219        ArchiveOperation::Archive
220    } else {
221        ArchiveOperation::Restore
222    };
223    // A restore with no target status is refused **before** the items are built, so
224    // a plan built from such a patch has no items to report a per-item problem on
225    // — the problem is with the patch, not with an issue.
226    let target_status = if request.archive {
227        None
228    } else {
229        request.target_status.clone()
230    };
231    let items = numbers
232        .iter()
233        .map(|number| ArchiveItemRequest {
234            number: *number,
235            selector: selectors
236                .get(number)
237                .cloned()
238                .unwrap_or_else(|| number.to_string()),
239            operation,
240            target_status: target_status.clone(),
241            reason: request.reason.clone(),
242        })
243        .collect();
244    ArchiveBatchRequest { operation, items }
245}
246
247/// The refusal a request carries, if any. Called by the preview and by the apply, so
248/// the two cannot disagree about whether a request is well-formed.
249pub fn refusal(request: &ArchiveBatchRequest) -> Option<ArchiveDiagnostic> {
250    if request.operation != ArchiveOperation::Restore {
251        return None;
252    }
253    if request
254        .items
255        .iter()
256        .all(|item| item.target_status.is_some())
257    {
258        return None;
259    }
260    Some(ArchiveDiagnostic {
261        problem: ArchiveProblem::MissingTargetStatus,
262        issue: None,
263        message: "restoring needs an explicit target status — there is no \\
264                  \"restore to the previous status\", so name the column to restore \\
265                  into"
266            .to_string(),
267    })
268}
269
270/// How many History entries applying this patch would append.
271///
272/// **One**, because `Store::set_archived` appends exactly one event per applied
273/// archive: `HistoryEventKind::Archived` (or `Restored` for the restore direction),
274/// the operation the file's new name records. The earlier version predicted zero and
275/// justified it by `HistoryEventKind` having no archive variant — true when the
276/// module was written, and false now that the model carries `Archived` and
277/// `Restored`. The single-issue path is the reference: `tix archive` writes that one
278/// entry, so a bulk archive that predicted none would disagree with it.
279///
280/// A patch with no archive request appends nothing.
281pub fn predicted_history_events(patch: &IssuePatch) -> usize {
282    usize::from(patch.archive.is_some())
283}
284
285/// The History kind an archive apply appends, or `None` when the patch carries no
286/// archive request.
287///
288/// `Some("archived")` for an archive and `Some("restored")` for a restore — the
289/// model's own spellings [`crate::model::HistoryEventKind::as_str`] produces, not a
290/// bulk-only vocabulary word. Read from the request's direction, because a patch is
291/// one or the other and never both.
292pub fn history_kind_for(patch: &IssuePatch) -> Option<&'static str> {
293    patch.archive.as_ref().map(|request| {
294        if request.archive {
295            "archived"
296        } else {
297            "restored"
298        }
299    })
300}
301
302/// Apply a confirmed archive plan.
303///
304/// Per selected issue: ask the store's archive service for the archive, wrapped in
305/// its own `Store::transaction` (#1580), and map the outcome into the shared batch
306/// vocabulary. The store does the archiving — this function only asks it — so the
307/// one archive implementation stays the store's.
308///
309/// The three outcomes are the shared ones:
310///
311/// - [`ItemOutcome::Applied`] — the store archived the issue; the file is at its new
312///   dot-prefixed name.
313/// - [`ItemOutcome::NoOp`] — the issue was already archived. `Store::set_archived`
314///   refuses a redundant call by name (`already_archived`), and a batch re-run must
315///   report that rather than fail, so it is a no-op and nothing is written.
316/// - [`ItemOutcome::Rejected`] — any other store refusal (an item that no longer
317///   resolves, a filename that cannot carry the marker). Nothing is rolled back:
318///   `contracts/operation-outcomes.md` says a confirmation authorizes independent
319///   item processing, not an all-or-nothing transaction.
320///
321/// The **restore** direction is refused before any write, and a restore without an
322/// explicit target status is refused first, unchanged — see the module doc.
323pub fn apply_archive(store: &Store, plan: &BatchPlan) -> Result<ApplyReport> {
324    // The confirmation gate first, for the same reason WP03's and WP04's are: the
325    // gate belongs to the apply, and a caller that reached the write path without
326    // one should learn that before the store is read.
327    if plan.confirmation.is_none() || !plan.confirmation_is_valid() {
328        return Err(TixError::InvalidInput(
329            "a bulk apply needs a confirmation for exactly this plan — preview it \
330             first, and preview again if the selection changed"
331                .to_string(),
332        ));
333    }
334    let request = plan
335        .patch
336        .archive
337        .clone()
338        .ok_or_else(|| TixError::InvalidInput("this plan carries no archive request".into()))?;
339
340    if !request.archive {
341        // A malformed restore is refused **before** the direction itself, because it
342        // would be refused anyway and the user can fix it now; #190's C-003 says a
343        // restore without an explicit target status is rejected.
344        if request.target_status.is_none() {
345            return Err(TixError::InvalidInput(format!(
346                "{}: restoring needs an explicit target status — there is no \\
347                 \"restore to the previous status\"",
348                ArchiveProblem::MissingTargetStatus.as_str()
349            )));
350        }
351        // The restore direction is not wired in this build. It has no caller, and its
352        // target-status contract is a separate question (the module doc), so it keeps
353        // a named refusal rather than being performed by a path nobody asked for.
354        return Err(TixError::InvalidInput(format!(
355            "archive delegation is not available for a restore: this build wires \
356             `bulk archive` to the store's archive service, but the bulk restore \
357             direction is not wired — mission {ARCHIVE_OWNER} owns the target-status \
358             contract, so this will delegate to its `bulk_restore` once that lands"
359        )));
360    }
361
362    // The per-item requests, built through the module's own adapter so each item is
363    // asked for with the **selector the user typed** rather than a number resolved
364    // back to a selector. The selection's selectors are zipped with its issues — the
365    // explicit ones, in the order they resolved — and `adapt` falls back to a
366    // number's own spelling for an item that came from a query.
367    let batch = adapt(
368        &plan.patch,
369        &plan.selection.numbers(),
370        &selector_map(&plan.selection),
371    )
372    .map_err(|diagnostic| TixError::InvalidInput(diagnostic.to_string()))?;
373
374    let mut outcomes = Vec::with_capacity(batch.len());
375    for item in &batch.items {
376        // One transaction per item (#1580): `Store::archive` renames the file and
377        // *then* appends the History event, and a failure between the two must put
378        // the file back at its active name rather than leave it archived without its
379        // audit entry. A per-item scope, never one over the whole batch — a failure
380        // on one issue must not roll a sibling back.
381        let result = store.transaction(|| store.archive(&item.selector));
382        match result {
383            Ok(issue) => outcomes.push((
384                item.number,
385                ItemOutcome::Applied {
386                    filename: issue.filename.clone(),
387                },
388            )),
389            // A redundant call is a **no-op**, not a failure: re-running a batch must
390            // report it, and `Store::set_archived` names it `already_archived`.
391            Err(error) if already_archived(&error) => {
392                outcomes.push((item.number, ItemOutcome::NoOp))
393            }
394            // Anything else is the store's judgement, and it has touched nothing.
395            Err(error) => outcomes.push((item.number, ItemOutcome::Rejected(error.to_string()))),
396        }
397    }
398
399    Ok(ApplyReport {
400        outcomes,
401        write_failures: Vec::new(),
402    })
403}
404
405/// The selectors the plan's issues were resolved from, keyed by number.
406///
407/// The selection's `selectors` are the ones the user typed, in the order they
408/// resolved, so zipping them with the issues attaches each selector to its issue. A
409/// query-added issue has no typed selector and is absent from the map; `adapt` falls
410/// back to the number's own spelling for it.
411fn selector_map(selection: &Selection) -> BTreeMap<u32, String> {
412    selection
413        .selectors()
414        .iter()
415        .zip(selection.issues())
416        .map(|(selector, issue)| (issue.number, selector.clone()))
417        .collect()
418}
419
420/// Whether the store refused because the issue is already archived.
421///
422/// `Store::set_archived` names the diagnostic (`already_archived:`) in an
423/// `InvalidInput` message — which renders with the variant's own prefix — rather than
424/// returning a typed outcome, so this reads the store's word from the rendered error.
425/// It is the one refusal a batch must treat as success-shaped: a re-run is idempotent
426/// and reports the no-op.
427fn already_archived(error: &TixError) -> bool {
428    error.to_string().contains("already_archived")
429}
430
431/// Whether this patch can be applied by [`apply_archive`]: true for an **archive**
432/// request, false for a restore (which this build deliberately does not wire).
433pub fn can_apply(patch: &IssuePatch) -> bool {
434    patch
435        .archive
436        .as_ref()
437        .is_some_and(|request| request.archive)
438}
439
440// The semantic audit — what #190's contract forbids a bulk archive from doing —
441// lives in `tests/bulk_archive.rs`, **not** here.
442//
443// It was in this file first, as `semantic_audit()` returning the rules as data, so a
444// caller would get the reasoning and the greps together. That does not work: the
445// rules are string patterns, so a rule list that lives in the file it audits
446// **contains its own patterns** and the grep fires on the audit. The case reported
447// `fs::rename` as a violation of C-005, having found it in the audit's own row for
448// C-005.
449//
450// The rules are test data, so they are in the test, next to the greps that enforce
451// them and the reasoning a reviewer needs to disagree with one. This module carries
452// only what production needs: the owning mission's name and the refusals.