Skip to main content

tix/bulk/
pair.rs

1//! The link/unlink pair (WP04).
2//!
3//! A pair is **two ends and a stated order**, which is why it is a type and not a
4//! list: an operation with two ends and no order cannot be applied without one of
5//! them being invented, and inventing one is a policy nobody chose. The direction,
6//! both selectors and the kind all arrive on [`crate::bulk::RelationshipOp`] and
7//! this module never infers any of them.
8//!
9//! ## Both writes are the store's, not this module's
10//!
11//! `Store::link` and `Store::unlink` already own everything that is subtle here:
12//! the canonical edge, the inverse spelling, back-references across both files,
13//! idempotence, one History event per accepted mutation, and atomic per-file
14//! writes. A bulk pair therefore resolves its endpoints, **delegates**, and reports
15//! — it does not write an endpoint itself, and `tests/bulk_pair.rs` asserts that
16//! the file a `tix link` would have written is the file a bulk pair writes.
17//!
18//! The contract's wording is the same: "delegate to the shared link/unlink write
19//! semantics … do not flatten a pair into a single endpoint write." Flattening is
20//! the specific failure this module exists to prevent: an operation with two ends
21//! that writes one of them.
22
23use crate::bulk::plan::PairPlan;
24use crate::bulk::{IssuePatch, RelationshipOp};
25use crate::error::{Result, TixError};
26use crate::relationships::{CanonicalEdge, RelationKind};
27use crate::storage::Store;
28
29/// What a pair resolution found.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct PairResolution {
32    /// The canonical edge, when both endpoints resolved and the pair is not
33    /// self-referential.
34    pub edge: Option<CanonicalEdge>,
35    /// The pair as typed, whether or not it resolves.
36    pub requested: RequestedPair,
37    /// Each endpoint's number, when **that** endpoint resolved.
38    ///
39    /// Per endpoint rather than one `edge`, because a missing `to` still leaves a
40    /// resolved `from` and a plan that said `from: Err` would be reporting a failure
41    /// that did not happen. A first version put the error on `from` unconditionally
42    //  and the WP02 case that checks "the `from` resolved" caught it.
43    pub from_number: Option<crate::id::IssueRef>,
44    pub to_number: Option<crate::id::IssueRef>,
45    /// Why it does not apply, if it does not.
46    pub diagnostic: Option<PairDiagnostic>,
47}
48
49/// The pair exactly as the user typed it, unresolved.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct RequestedPair {
52    pub link: bool,
53    pub from: String,
54    pub to: String,
55    pub kind: RelationKind,
56}
57
58impl From<&RelationshipOp> for RequestedPair {
59    fn from(op: &RelationshipOp) -> Self {
60        Self {
61            link: op.link,
62            from: op.from.clone(),
63            to: op.to.clone(),
64            kind: op.kind,
65        }
66    }
67}
68
69impl RequestedPair {
70    /// The operation's own verb, so a message says "link" or "unlink" rather than
71    /// "a relationship operation".
72    pub fn verb(&self) -> &'static str {
73        if self.link { "link" } else { "unlink" }
74    }
75}
76
77/// Why one pair cannot be applied.
78///
79/// A **stable** vocabulary, because T019 asks for a stable per-pair diagnostic and
80/// a message that varies with the wording of a selector is not stable. Each variant
81/// maps to exactly one situation, and the situation is named rather than embedded
82/// in prose.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum PairProblem {
85    /// A selector matched no issue.
86    Missing,
87    /// A selector matched more than one issue.
88    Ambiguous,
89    /// Both selectors name the same issue.
90    SelfLink,
91    /// The store refused it: a duplicate, or a cycle the graph closes.
92    Refused,
93}
94
95impl PairProblem {
96    pub fn as_str(self) -> &'static str {
97        match self {
98            Self::Missing => "missing_target",
99            Self::Ambiguous => "ambiguous_target",
100            Self::SelfLink => "self_link",
101            Self::Refused => "refused",
102        }
103    }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct PairDiagnostic {
108    pub problem: PairProblem,
109    /// Which endpoint: `"from"` or `"to"`, or `""` when the problem is the pair
110    /// rather than an end.
111    pub endpoint: &'static str,
112    pub message: String,
113}
114
115impl std::fmt::Display for PairDiagnostic {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        if self.endpoint.is_empty() {
118            write!(f, "{}: {}", self.problem.as_str(), self.message)
119        } else {
120            write!(
121                f,
122                "{} on {}: {}",
123                self.problem.as_str(),
124                self.endpoint,
125                self.message
126            )
127        }
128    }
129}
130
131/// Resolve one pair against the store, without writing anything.
132///
133/// The three refusals are **this module's** and the fourth is the store's:
134///
135/// - `missing_target` and `ambiguous_target` come from the selector, and are the
136///   **existing** diagnostics — `parse_selector` is what produces them, and this
137///   module does not reword them. A bulk pair that invented its own wording for an
138///   ambiguous fragment would be a second diagnostic for one situation.
139/// - `self_link` is `1 -> 1`: a cycle of length one, refused here rather than
140///   handed to the graph, because the graph's answer for it is a cycle error and a
141///   plan wants to say "self" rather than "cycle".
142/// - `refused` is whatever the store says — a duplicate, or a cycle the edge
143///   closes. The store's judgement is the judgement.
144pub fn resolve_pair(store: &Store, op: &RelationshipOp) -> PairResolution {
145    let requested = RequestedPair::from(op);
146    let resolve = |raw: &str,
147                   endpoint: &'static str|
148     -> (Option<crate::id::IssueRef>, Option<PairDiagnostic>) {
149        match crate::commands::parse_selector(store, raw) {
150            Err(e) => {
151                // Ambiguity and "no such issue" are both selector failures, and both
152                // arrive as an `Err` — so the question is which one, and the answer
153                // has to be something **stable**. The error *variant* is not enough
154                // on its own (`InvalidInput` could be anything), so this matches
155                // `commands::AMBIGUITY_PREFIX`, which is the marker the selector
156                // itself uses and which `commands::mod.rs:465` already matches on
157                // for the same purpose.
158                //
159                // Matching on the prose instead — `"ambiguous".contains(...)` — is
160                // what a first draft of this comment considered, and it is the thing
161                // T019's "stable" forbids: a reworded message silently becomes a
162                // different diagnosis. The message is still carried, for a human.
163                let problem = match &e {
164                    TixError::InvalidInput(m)
165                        if m.starts_with(crate::commands::AMBIGUITY_PREFIX) =>
166                    {
167                        PairProblem::Ambiguous
168                    }
169                    _ => PairProblem::Missing,
170                };
171                (
172                    None,
173                    Some(PairDiagnostic {
174                        problem,
175                        endpoint,
176                        message: e.to_string(),
177                    }),
178                )
179            }
180            Ok(selector) => {
181                // The store's own list is what makes *ambiguity* observable: a
182                // selector that `parse_selector` accepts is already unique, so the
183                // ambiguity has to be measured here, against the same corpus.
184                match store.get(selector) {
185                    Ok(issue) => (Some(crate::id::canonical_ref(&issue)), None),
186                    Err(e) => (
187                        None,
188                        Some(PairDiagnostic {
189                            problem: PairProblem::Missing,
190                            endpoint,
191                            message: e.to_string(),
192                        }),
193                    ),
194                }
195            }
196        }
197    };
198
199    let (from, from_problem) = resolve(&op.from, "from");
200    let (to, to_problem) = resolve(&op.to, "to");
201    let problem = from_problem.or(to_problem);
202
203    if let Some(diagnostic) = problem {
204        return PairResolution {
205            edge: None,
206            from_number: from,
207            to_number: to,
208            requested,
209            diagnostic: Some(diagnostic),
210        };
211    }
212    let (from, to) = (from.expect("checked"), to.expect("checked"));
213
214    if from == to {
215        return PairResolution {
216            edge: None,
217            from_number: Some(from),
218            to_number: Some(to),
219            requested,
220            diagnostic: Some(PairDiagnostic {
221                problem: PairProblem::SelfLink,
222                endpoint: "",
223                message: format!(
224                    "#{} {} itself — a relationship needs two issues",
225                    from,
226                    op.kind.as_str()
227                ),
228            }),
229        };
230    }
231
232    let edge = CanonicalEdge::new(from, to, op.kind);
233    PairResolution {
234        edge: Some(edge),
235        from_number: Some(from),
236        to_number: Some(to),
237        requested,
238        diagnostic: None,
239    }
240}
241
242/// Resolve against a store, as a [`PairPlan`] the plan builder can consume.
243///
244/// The shape the plan wants: a plan reports a pair, and WP02's `PairPlan` is what a
245/// surface renders. This adapter exists so `build_plan` stays free of a store and
246/// the closure is the only place the two meet.
247pub fn plan_for(store: &Store, op: &RelationshipOp) -> PairPlan {
248    let resolution = resolve_pair(store, op);
249    // Each endpoint gets its own answer: the one that failed reports the diagnostic,
250    // the one that succeeded reports its number. A self-link resolved **both** and
251    // failed on the pair, so both report `Ok` and only `diagnostic` is set — which
252    // is what distinguishes it from a missing endpoint without a fourth variant.
253    let end = |number: Option<crate::id::IssueRef>,
254               endpoint: &str,
255               diagnostic: &Option<PairDiagnostic>| match (
256        number,
257        diagnostic.as_ref().map(|d| d.endpoint),
258    ) {
259        (Some(n), _) => Ok(n),
260        (None, Some(failed)) if failed == endpoint => {
261            Err(diagnostic.as_ref().expect("checked").message.clone())
262        }
263        _ => Err(String::new()),
264    };
265    match &resolution.diagnostic {
266        Some(diagnostic) => PairPlan::refused(
267            op.link,
268            end(resolution.from_number, "from", &resolution.diagnostic),
269            end(resolution.to_number, "to", &resolution.diagnostic),
270            diagnostic.clone(),
271        ),
272        None => PairPlan::resolved(op.link, resolution.edge.expect("checked")),
273    }
274}
275
276/// What applying a pair did.
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub enum PairOutcome {
279    /// The edge was written. Both endpoints are named, because a pair is two ends
280    /// and a report that names one of them is a flattened write with extra steps.
281    Applied {
282        from: crate::id::IssueRef,
283        to: crate::id::IssueRef,
284        identity: String,
285        /// The files the store wrote, in the store's own order. More than one is
286        /// normal: `unlink` removes a redundant pair of rows from two files.
287        files: Vec<String>,
288    },
289    /// The edge was already present (a link) or already absent (an unlink).
290    /// Nothing was written and no History event was appended.
291    NoOp { identity: String },
292    /// Refused. Neither endpoint was touched.
293    Refused(PairDiagnostic),
294}
295
296impl PairOutcome {
297    pub fn as_line(&self) -> String {
298        match self {
299            Self::Applied {
300                from,
301                to,
302                identity,
303                files,
304            } => format!(
305                "{} {} -> {} ({identity}); wrote {}",
306                if files.len() == 1 {
307                    "linked"
308                } else {
309                    "unlinked"
310                },
311                from.human(),
312                to.human(),
313                files.join(", ")
314            ),
315            Self::NoOp { identity } => {
316                format!("{identity} was already in that state; nothing written")
317            }
318            Self::Refused(diagnostic) => format!("refused: {diagnostic}"),
319        }
320    }
321
322    /// The shared status word (`NFR-003`).
323    ///
324    /// The same three words `ItemOutcome::status` uses, from the same vocabulary, so a
325    /// surface reporting a batch of pairs and a surface reporting a batch of field
326    /// edits produce comparable per-item statuses. A pair has no "failed" variant —
327    /// a write that fails comes back as an `Err` from `apply_pair` — so the three
328    /// words are the three it can be, and a fourth would be a status no pair can
329    /// reach.
330    pub fn status(&self) -> &'static str {
331        match self {
332            Self::Applied { .. } => "applied",
333            Self::NoOp { .. } => "no-op",
334            Self::Refused(_) => "rejected",
335        }
336    }
337
338    /// The files the outcome wrote, or none.
339    ///
340    /// A pair writes **two** files, so there is no single filename and the projection
341    /// a surface needs is a list. Returning a list rather than a joined string is what
342    /// lets the TUI and MCP print the same thing without either inventing a separator.
343    pub fn files(&self) -> &[String] {
344        match self {
345            Self::Applied { files, .. } => files,
346            _ => &[],
347        }
348    }
349
350    pub fn is_applied(&self) -> bool {
351        matches!(self, Self::Applied { .. })
352    }
353}
354
355/// Apply a confirmed plan's pair. Writes nothing but the pair's own endpoints.
356///
357/// The confirmation gate is **this module's own** and is checked first, for the same
358/// reason WP03's is: the gate belongs to the apply, and a caller that reached the
359/// write path without one should learn that before the store is read.
360pub fn apply_pair(store: &Store, plan: &crate::bulk::BatchPlan) -> Result<PairOutcome> {
361    if plan.confirmation.is_none() || !plan.confirmation_is_valid() {
362        return Err(TixError::InvalidInput(
363            "a bulk apply needs a confirmation for exactly this plan — preview it \
364             first, and preview again if the selection or the patch changed"
365                .to_string(),
366        ));
367    }
368    let op = plan
369        .patch
370        .relationship
371        .as_ref()
372        .ok_or_else(|| TixError::InvalidInput("this plan carries no relationship".into()))?;
373    // A pair **and** a field change in one patch has no agreed order, and an order
374    // invented here would be a policy nobody chose. The patch's own structural
375    // check already refuses the combination, so this is belt and braces for a
376    // caller that bypassed the constructor.
377    if !owns(&plan.patch) {
378        return Err(TixError::InvalidInput(
379            "this plan mixes a relationship with field changes, which have no \
380             agreed order — preview them as two plans"
381                .to_string(),
382        ));
383    }
384
385    let resolution = resolve_pair(store, op);
386    if let Some(diagnostic) = resolution.diagnostic {
387        return Ok(PairOutcome::Refused(diagnostic));
388    }
389    let edge = resolution.edge.expect("checked");
390
391    // The delegation. `Store::link` and `Store::unlink` own the canonical edge, the
392    // inverse spelling, back-references, idempotence, one History event per
393    // accepted mutation, and atomic per-file writes. A refusal from either is the
394    // store's judgement, and it has touched nothing.
395    //
396    // One transaction for the pair's write (#1580), matching the per-item scope
397    // `bulk::apply_fields` uses: `unlink` writes every file that carries a matching
398    // row, so a failure after the first must not leave the edge half-removed. A
399    // bulk pair is one selected issue, never a transaction over the whole batch.
400    let outcome = store.transaction(|| {
401        if op.link {
402            store.link(&op.from, &op.to, edge.kind)
403        } else {
404            store.unlink(&op.from, &op.to, edge.kind)
405        }
406    });
407    let outcome = match outcome {
408        Ok(outcome) => outcome,
409        Err(e) => {
410            return Ok(PairOutcome::Refused(PairDiagnostic {
411                problem: PairProblem::Refused,
412                endpoint: "",
413                message: e.to_string(),
414            }));
415        }
416    };
417
418    let identity = outcome.relationship.id.clone();
419    if !outcome.changed {
420        return Ok(PairOutcome::NoOp { identity });
421    }
422    let files = outcome
423        .written
424        .as_ref()
425        .map(|i| vec![i.filename.clone()])
426        .unwrap_or_default();
427    Ok(PairOutcome::Applied {
428        from: outcome.relationship.from,
429        to: outcome.relationship.to,
430        identity,
431        files,
432    })
433}
434
435/// The one-line summary a surface prints, with the count named even when zero.
436pub fn summary(outcome: &PairOutcome) -> String {
437    match outcome {
438        PairOutcome::Applied { .. } => "1 relationship applied".to_string(),
439        PairOutcome::NoOp { .. } => "1 relationship already in that state".to_string(),
440        PairOutcome::Refused(d) => format!("1 relationship refused: {d}"),
441    }
442}
443
444/// Whether a patch is a **pure** pair, which is what `apply_pair` requires.
445///
446/// A field change and a pair in one patch is refused at the patch's structural
447/// check, so this is belt and braces: the check lives in the type and this
448/// re-reads it, because a caller that bypasses the patch constructor should still
449/// be stopped here rather than half-applied.
450///
451/// Note the first version asked `is_field_only`, which is **false** for a pure pair
452/// — so `apply_pair` refused every pair plan it was handed, and eleven cases failed
453/// with "this plan mixes a relationship with field changes" on a plan that named
454/// nothing but a relationship. A predicate whose name reads as the answer to a
455/// question it answers the *negation* of is a trap, so both predicates are now
456/// named for the path they belong to: `is_field_path` and `is_pair_path`.
457pub fn owns(patch: &IssuePatch) -> bool {
458    crate::bulk::field_apply::is_pair_path(patch)
459}