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 three things and refuses a fourth.
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 a **History prediction** of zero, and says why.
26//!
27//! ## What it refuses
28//!
29//! Applying. The `issue-archive-restore-01M3C4JF` mission is `planned` — all five of
30//! its work packages — so `bulk_archive`/`bulk_restore` do not exist.
31//! [`apply_archive`] therefore returns a named refusal and touches nothing. A bulk
32//! path that "worked" without the service would be the second archive implementation
33//! the contract prohibits, and it would be the one with no tests behind it.
34
35use std::collections::BTreeMap;
36
37use crate::bulk::{ArchiveRequest, IssuePatch};
38use crate::error::{Result, TixError};
39
40/// The mission that owns archiving, named in the refusal so a user can find it.
41///
42/// A constant rather than a string in the message, because a refusal that says
43/// "not available" and a comment that says "see #190" are two facts a reader has to
44/// join by hand. The message carries the name.
45pub const ARCHIVE_OWNER: &str = "issue-archive-restore-01M3C4JF";
46
47/// One issue's request to the archive service.
48///
49/// Issue identity is carried as **both** the number and the selector the user typed,
50/// because the service takes selectors (`bulk_archive` is "issue selectors") and a
51/// bulk preview has numbers. Resolving a number back to a selector would be a
52/// second resolution path, and the selector is what the user asked for.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ArchiveItemRequest {
55 /// The issue number, stable across an archive and a restore (C-002 in #190).
56 pub number: u32,
57 /// The selector as typed, or the number's own spelling when the source was a
58 /// query rather than explicit selectors.
59 pub selector: String,
60 /// The direction, as the service names it.
61 pub operation: ArchiveOperation,
62 /// The explicit target status, for a restore.
63 ///
64 /// #190's C-003: "Restore requires an explicit `target_status`. A restore
65 /// without one is rejected; there is no 'restore to the previous status'
66 /// behavior." So this is not optional on a restore, and [`ArchiveRequest`]
67 /// carries it for the same reason — see its own doc comment.
68 pub target_status: Option<String>,
69 /// The reason the user supplied, preserved verbatim.
70 ///
71 /// Not interpreted here. A reason is text the service records, and a bulk side
72 /// that summarised it would be authoring a second version of the record.
73 pub reason: Option<String>,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum ArchiveOperation {
78 Archive,
79 Restore,
80}
81
82impl ArchiveOperation {
83 /// The service's own verb, so a diagnostic names `bulk_archive` or
84 /// `bulk_restore` rather than "an archive operation".
85 pub fn service_fn(self) -> &'static str {
86 match self {
87 Self::Archive => "bulk_archive",
88 Self::Restore => "bulk_restore",
89 }
90 }
91
92 pub fn as_str(self) -> &'static str {
93 match self {
94 Self::Archive => "archive",
95 Self::Restore => "restore",
96 }
97 }
98}
99
100/// The whole batch's request, in the shape #190's `bulk_archive` takes.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct ArchiveBatchRequest {
103 pub operation: ArchiveOperation,
104 /// One entry per selected issue, in the plan's order. #190's bulk rules require
105 /// "exactly one outcome per selected issue", so the request is one-per-issue and
106 /// the order is the plan's.
107 pub items: Vec<ArchiveItemRequest>,
108}
109
110impl ArchiveBatchRequest {
111 pub fn len(&self) -> usize {
112 self.items.len()
113 }
114
115 pub fn is_empty(&self) -> bool {
116 self.items.is_empty()
117 }
118
119 /// The one-line summary, with the count named even when zero.
120 pub fn summary(&self) -> String {
121 format!(
122 "{} {} request for {} issue{}",
123 self.operation.service_fn(),
124 self.operation.as_str(),
125 self.items.len(),
126 if self.items.len() == 1 { "" } else { "s" }
127 )
128 }
129}
130
131/// Why an archive request cannot even be built.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum ArchiveProblem {
134 /// A restore with no target status. #190's C-003.
135 MissingTargetStatus,
136 /// The selection carried an issue the service could not be asked about —
137 /// unreachable through `Selection`, so this exists for a caller that builds a
138 /// request by hand.
139 UnknownIssue,
140}
141
142impl ArchiveProblem {
143 pub fn as_str(self) -> &'static str {
144 match self {
145 Self::MissingTargetStatus => "missing_target_status",
146 Self::UnknownIssue => "unknown_issue",
147 }
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct ArchiveDiagnostic {
153 pub problem: ArchiveProblem,
154 /// The issue, when the problem belongs to one.
155 pub issue: Option<u32>,
156 pub message: String,
157}
158
159impl std::fmt::Display for ArchiveDiagnostic {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 match self.issue {
162 Some(issue) => write!(f, "#{}: {}: {}", issue, self.problem.as_str(), self.message),
163 None => write!(f, "{}: {}", self.problem.as_str(), self.message),
164 }
165 }
166}
167
168/// Build the service request from a confirmed plan's patch.
169///
170/// **Pure**: reads the patch and the plan's items, writes nothing, and needs no
171/// store — so a preview can be rendered with no filesystem at all, which is the
172/// same reason [`crate::bulk::plan::build_plan`] takes an `issues_dir` rather than a
173/// `Store`.
174///
175/// The refusal is returned rather than raised, because a plan must be *reportable*:
176/// `tix bulk archive --preview` on a restore with no target status should print the
177/// diagnostic and exit non-zero, not panic, and the outcome vector is where that
178/// diagnostic belongs.
179pub fn adapt(
180 patch: &IssuePatch,
181 numbers: &[u32],
182 selectors: &BTreeMap<u32, String>,
183) -> std::result::Result<ArchiveBatchRequest, ArchiveDiagnostic> {
184 let request = patch.archive.clone().ok_or_else(|| ArchiveDiagnostic {
185 problem: ArchiveProblem::UnknownIssue,
186 issue: None,
187 message: "this patch carries no archive request".to_string(),
188 })?;
189 Ok(build(request, numbers, selectors))
190}
191
192/// The build half of [`adapt`], taking the direction and target directly.
193///
194/// Separate so the refusal lives in one place and the construction is total: a
195/// caller that has already validated can build without re-deciding, and a caller
196/// that has not can go through [`adapt`].
197fn build(
198 request: ArchiveRequest,
199 numbers: &[u32],
200 selectors: &BTreeMap<u32, String>,
201) -> ArchiveBatchRequest {
202 let operation = if request.archive {
203 ArchiveOperation::Archive
204 } else {
205 ArchiveOperation::Restore
206 };
207 // A restore with no target status is refused **before** the items are built, so
208 // a plan built from such a patch has no items to report a per-item problem on
209 // — the problem is with the patch, not with an issue.
210 let target_status = if request.archive {
211 None
212 } else {
213 request.target_status.clone()
214 };
215 let items = numbers
216 .iter()
217 .map(|number| ArchiveItemRequest {
218 number: *number,
219 selector: selectors
220 .get(number)
221 .cloned()
222 .unwrap_or_else(|| number.to_string()),
223 operation,
224 target_status: target_status.clone(),
225 reason: request.reason.clone(),
226 })
227 .collect();
228 ArchiveBatchRequest { operation, items }
229}
230
231/// The refusal a request carries, if any. Called by the preview and by the apply, so
232/// the two cannot disagree about whether a request is well-formed.
233pub fn refusal(request: &ArchiveBatchRequest) -> Option<ArchiveDiagnostic> {
234 if request.operation != ArchiveOperation::Restore {
235 return None;
236 }
237 if request
238 .items
239 .iter()
240 .all(|item| item.target_status.is_some())
241 {
242 return None;
243 }
244 Some(ArchiveDiagnostic {
245 problem: ArchiveProblem::MissingTargetStatus,
246 issue: None,
247 message: "restoring needs an explicit target status — there is no \\
248 \"restore to the previous status\", so name the column to restore \\
249 into"
250 .to_string(),
251 })
252}
253
254/// How many History entries applying this patch would append.
255///
256/// **Zero**, and the reason is checkable rather than asserted: `HistoryEventKind` has
257/// no archive variant. Its eight spellings are relationship-added, -removed,
258/// -retargeted, blocked-move-acknowledged, parent-set, -changed, -cleared and
259/// transition-accepted — none of which is an archive, and #190's contract says the
260/// event comes from the archive service's own `ArchiveRecord` plus "one History
261/// entry" whose kind #190 will add when it lands.
262///
263/// So a bulk archive predicts **no** History event, exactly as a bulk field patch
264/// does after WP03's correction, and for the same reason: the single-issue path is
265/// the reference and it has no archive operation to be parallel to yet. The
266/// prediction becomes one the day #190 adds the variant, and the case that pins this
267/// is written against the model's **own** spellings so it will notice.
268pub fn predicted_history_events(_patch: &IssuePatch) -> usize {
269 0
270}
271
272/// The History kind an archive apply would append, if it appended one.
273///
274/// `None`, and `None` is the honest answer rather than a placeholder string: there is
275/// no `HistoryEventKind` to name. A `Some("archived")` here would be a bulk-only
276/// History vocabulary — the same defect WP03 found and removed for `field_updated`.
277pub fn history_kind_for(_patch: &IssuePatch) -> Option<&'static str> {
278 None
279}
280
281/// Apply a confirmed archive plan.
282///
283/// **Refuses.** The `issue-archive-restore-01M3C4JF` mission is `planned`, so
284/// `bulk_archive`/`bulk_restore` do not exist, and this function is the place that
285/// says so rather than the place that works around it.
286///
287/// The refusal is a named `Err`, not an empty outcome vector, because an empty
288/// vector reads as "nothing to do" and a user who typed `tix bulk archive --apply`
289/// deserves to be told the feature is not there. The message names the owning
290/// mission, so the refusal is a route rather than a wall.
291pub fn apply_archive(plan: &crate::bulk::BatchPlan) -> Result<crate::bulk::plan::ItemDiagnostic> {
292 // The confirmation gate first, for the same reason WP03's and WP04's are: the
293 // gate belongs to the apply, and a caller that reached the write path without
294 // one should learn that before anything else.
295 if plan.confirmation.is_none() || !plan.confirmation_is_valid() {
296 return Err(TixError::InvalidInput(
297 "a bulk apply needs a confirmation for exactly this plan — preview it \
298 first, and preview again if the selection changed"
299 .to_string(),
300 ));
301 }
302 let request = plan
303 .patch
304 .archive
305 .clone()
306 .ok_or_else(|| TixError::InvalidInput("this plan carries no archive request".into()))?;
307
308 // A malformed request is refused **before** the missing service, because it would
309 // be refused anyway and the user can fix it now.
310 if !request.archive && request.target_status.is_none() {
311 return Err(TixError::InvalidInput(format!(
312 "{}: restoring needs an explicit target status — there is no \\
313 \"restore to the previous status\"",
314 ArchiveProblem::MissingTargetStatus.as_str()
315 )));
316 }
317
318 // The operation word comes from the **request already in hand**, not from a
319 // second read of the plan. Two reads of the same value in one function is two
320 // places to disagree, and the first version did exactly that.
321 let operation = if request.archive {
322 "archive"
323 } else {
324 "restore"
325 };
326 Err(TixError::InvalidInput(format!(
327 "archive delegation is not available: the archive service is part of mission \
328 {ARCHIVE_OWNER}, which has not been implemented — this path will delegate \
329 to its `bulk_{operation}` once it lands, and it will not implement archiving \
330 itself"
331 )))
332}
333
334/// Whether this module can apply the patch. Always false until #190 lands, and
335/// asserted as such by `tests/bulk_archive.rs` so the day it becomes true somebody
336/// has to notice.
337pub fn can_apply(_patch: &IssuePatch) -> bool {
338 false
339}
340
341// The semantic audit — what #190's contract forbids a bulk archive from doing —
342// lives in `tests/bulk_archive.rs`, **not** here.
343//
344// It was in this file first, as `semantic_audit()` returning the rules as data, so a
345// caller would get the reasoning and the greps together. That does not work: the
346// rules are string patterns, so a rule list that lives in the file it audits
347// **contains its own patterns** and the grep fires on the audit. The case reported
348// `fs::rename` as a violation of C-005, having found it in the audit's own row for
349// C-005.
350//
351// The rules are test data, so they are in the test, next to the greps that enforce
352// them and the reasoning a reviewer needs to disagree with one. This module carries
353// only what production needs: the owning mission's name and the refusals.