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.