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 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.