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<u32>,
44    pub to_number: Option<u32>,
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, endpoint: &'static str| -> (Option<u32>, Option<PairDiagnostic>) {
147        match crate::commands::parse_selector(store, raw) {
148            Err(e) => {
149                // Ambiguity and "no such issue" are both selector failures, and both
150                // arrive as an `Err` — so the question is which one, and the answer
151                // has to be something **stable**. The error *variant* is not enough
152                // on its own (`InvalidInput` could be anything), so this matches
153                // `commands::AMBIGUITY_PREFIX`, which is the marker the selector
154                // itself uses and which `commands::mod.rs:465` already matches on
155                // for the same purpose.
156                //
157                // Matching on the prose instead — `"ambiguous".contains(...)` — is
158                // what a first draft of this comment considered, and it is the thing
159                // T019's "stable" forbids: a reworded message silently becomes a
160                // different diagnosis. The message is still carried, for a human.
161                let problem = match &e {
162                    TixError::InvalidInput(m)
163                        if m.starts_with(crate::commands::AMBIGUITY_PREFIX) =>
164                    {
165                        PairProblem::Ambiguous
166                    }
167                    _ => PairProblem::Missing,
168                };
169                (
170                    None,
171                    Some(PairDiagnostic {
172                        problem,
173                        endpoint,
174                        message: e.to_string(),
175                    }),
176                )
177            }
178            Ok(selector) => {
179                // The store's own list is what makes *ambiguity* observable: a
180                // selector that `parse_selector` accepts is already unique, so the
181                // ambiguity has to be measured here, against the same corpus.
182                match store.get(selector) {
183                    Ok(issue) => (Some(issue.number), None),
184                    Err(e) => (
185                        None,
186                        Some(PairDiagnostic {
187                            problem: PairProblem::Missing,
188                            endpoint,
189                            message: e.to_string(),
190                        }),
191                    ),
192                }
193            }
194        }
195    };
196
197    let (from, from_problem) = resolve(&op.from, "from");
198    let (to, to_problem) = resolve(&op.to, "to");
199    let problem = from_problem.or(to_problem);
200
201    if let Some(diagnostic) = problem {
202        return PairResolution {
203            edge: None,
204            from_number: from,
205            to_number: to,
206            requested,
207            diagnostic: Some(diagnostic),
208        };
209    }
210    let (from, to) = (from.expect("checked"), to.expect("checked"));
211
212    if from == to {
213        return PairResolution {
214            edge: None,
215            from_number: Some(from),
216            to_number: Some(to),
217            requested,
218            diagnostic: Some(PairDiagnostic {
219                problem: PairProblem::SelfLink,
220                endpoint: "",
221                message: format!(
222                    "#{} {} itself — a relationship needs two issues",
223                    from,
224                    op.kind.as_str()
225                ),
226            }),
227        };
228    }
229
230    let edge = CanonicalEdge::new(from, to, op.kind);
231    PairResolution {
232        edge: Some(edge),
233        from_number: Some(from),
234        to_number: Some(to),
235        requested,
236        diagnostic: None,
237    }
238}
239
240/// Resolve against a store, as a [`PairPlan`] the plan builder can consume.
241///
242/// The shape the plan wants: a plan reports a pair, and WP02's `PairPlan` is what a
243/// surface renders. This adapter exists so `build_plan` stays free of a store and
244/// the closure is the only place the two meet.
245pub fn plan_for(store: &Store, op: &RelationshipOp) -> PairPlan {
246    let resolution = resolve_pair(store, op);
247    // Each endpoint gets its own answer: the one that failed reports the diagnostic,
248    // the one that succeeded reports its number. A self-link resolved **both** and
249    // failed on the pair, so both report `Ok` and only `diagnostic` is set — which
250    // is what distinguishes it from a missing endpoint without a fourth variant.
251    let end = |number: Option<u32>, endpoint: &str, diagnostic: &Option<PairDiagnostic>| match (
252        number,
253        diagnostic.as_ref().map(|d| d.endpoint),
254    ) {
255        (Some(n), _) => Ok(n),
256        (None, Some(failed)) if failed == endpoint => {
257            Err(diagnostic.as_ref().expect("checked").message.clone())
258        }
259        _ => Err(String::new()),
260    };
261    match &resolution.diagnostic {
262        Some(diagnostic) => PairPlan::refused(
263            op.link,
264            end(resolution.from_number, "from", &resolution.diagnostic),
265            end(resolution.to_number, "to", &resolution.diagnostic),
266            diagnostic.clone(),
267        ),
268        None => PairPlan::resolved(op.link, resolution.edge.expect("checked")),
269    }
270}
271
272/// What applying a pair did.
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub enum PairOutcome {
275    /// The edge was written. Both endpoints are named, because a pair is two ends
276    /// and a report that names one of them is a flattened write with extra steps.
277    Applied {
278        from: u32,
279        to: u32,
280        identity: String,
281        /// The files the store wrote, in the store's own order. More than one is
282        /// normal: `unlink` removes a redundant pair of rows from two files.
283        files: Vec<String>,
284    },
285    /// The edge was already present (a link) or already absent (an unlink).
286    /// Nothing was written and no History event was appended.
287    NoOp { identity: String },
288    /// Refused. Neither endpoint was touched.
289    Refused(PairDiagnostic),
290}
291
292impl PairOutcome {
293    pub fn as_line(&self) -> String {
294        match self {
295            Self::Applied {
296                from,
297                to,
298                identity,
299                files,
300            } => format!(
301                "{} #{from} -> #{to} ({identity}); wrote {}",
302                if files.len() == 1 {
303                    "linked"
304                } else {
305                    "unlinked"
306                },
307                files.join(", ")
308            ),
309            Self::NoOp { identity } => {
310                format!("{identity} was already in that state; nothing written")
311            }
312            Self::Refused(diagnostic) => format!("refused: {diagnostic}"),
313        }
314    }
315
316    /// The shared status word (`NFR-003`).
317    ///
318    /// The same three words `ItemOutcome::status` uses, from the same vocabulary, so a
319    /// surface reporting a batch of pairs and a surface reporting a batch of field
320    /// edits produce comparable per-item statuses. A pair has no "failed" variant —
321    /// a write that fails comes back as an `Err` from `apply_pair` — so the three
322    /// words are the three it can be, and a fourth would be a status no pair can
323    /// reach.
324    pub fn status(&self) -> &'static str {
325        match self {
326            Self::Applied { .. } => "applied",
327            Self::NoOp { .. } => "no-op",
328            Self::Refused(_) => "rejected",
329        }
330    }
331
332    /// The files the outcome wrote, or none.
333    ///
334    /// A pair writes **two** files, so there is no single filename and the projection
335    /// a surface needs is a list. Returning a list rather than a joined string is what
336    /// lets the TUI and MCP print the same thing without either inventing a separator.
337    pub fn files(&self) -> &[String] {
338        match self {
339            Self::Applied { files, .. } => files,
340            _ => &[],
341        }
342    }
343
344    pub fn is_applied(&self) -> bool {
345        matches!(self, Self::Applied { .. })
346    }
347}
348
349/// Apply a confirmed plan's pair. Writes nothing but the pair's own endpoints.
350///
351/// The confirmation gate is **this module's own** and is checked first, for the same
352/// reason WP03's is: the gate belongs to the apply, and a caller that reached the
353/// write path without one should learn that before the store is read.
354pub fn apply_pair(store: &Store, plan: &crate::bulk::BatchPlan) -> Result<PairOutcome> {
355    if plan.confirmation.is_none() || !plan.confirmation_is_valid() {
356        return Err(TixError::InvalidInput(
357            "a bulk apply needs a confirmation for exactly this plan — preview it \
358             first, and preview again if the selection or the patch changed"
359                .to_string(),
360        ));
361    }
362    let op = plan
363        .patch
364        .relationship
365        .as_ref()
366        .ok_or_else(|| TixError::InvalidInput("this plan carries no relationship".into()))?;
367    // A pair **and** a field change in one patch has no agreed order, and an order
368    // invented here would be a policy nobody chose. The patch's own structural
369    // check already refuses the combination, so this is belt and braces for a
370    // caller that bypassed the constructor.
371    if !owns(&plan.patch) {
372        return Err(TixError::InvalidInput(
373            "this plan mixes a relationship with field changes, which have no \
374             agreed order — preview them as two plans"
375                .to_string(),
376        ));
377    }
378
379    let resolution = resolve_pair(store, op);
380    if let Some(diagnostic) = resolution.diagnostic {
381        return Ok(PairOutcome::Refused(diagnostic));
382    }
383    let edge = resolution.edge.expect("checked");
384
385    // The delegation. `Store::link` and `Store::unlink` own the canonical edge, the
386    // inverse spelling, back-references, idempotence, one History event per
387    // accepted mutation, and atomic per-file writes. A refusal from either is the
388    // store's judgement, and it has touched nothing.
389    let outcome = if op.link {
390        store.link(&op.from, &op.to, edge.kind)
391    } else {
392        store.unlink(&op.from, &op.to, edge.kind)
393    };
394    let outcome = match outcome {
395        Ok(outcome) => outcome,
396        Err(e) => {
397            return Ok(PairOutcome::Refused(PairDiagnostic {
398                problem: PairProblem::Refused,
399                endpoint: "",
400                message: e.to_string(),
401            }));
402        }
403    };
404
405    let identity = outcome.relationship.id.clone();
406    if !outcome.changed {
407        return Ok(PairOutcome::NoOp { identity });
408    }
409    let files = outcome
410        .written
411        .as_ref()
412        .map(|i| vec![i.filename.clone()])
413        .unwrap_or_default();
414    Ok(PairOutcome::Applied {
415        from: outcome.relationship.from,
416        to: outcome.relationship.to,
417        identity,
418        files,
419    })
420}
421
422/// The one-line summary a surface prints, with the count named even when zero.
423pub fn summary(outcome: &PairOutcome) -> String {
424    match outcome {
425        PairOutcome::Applied { .. } => "1 relationship applied".to_string(),
426        PairOutcome::NoOp { .. } => "1 relationship already in that state".to_string(),
427        PairOutcome::Refused(d) => format!("1 relationship refused: {d}"),
428    }
429}
430
431/// Whether a patch is a **pure** pair, which is what `apply_pair` requires.
432///
433/// A field change and a pair in one patch is refused at the patch's structural
434/// check, so this is belt and braces: the check lives in the type and this
435/// re-reads it, because a caller that bypasses the patch constructor should still
436/// be stopped here rather than half-applied.
437///
438/// Note the first version asked `is_field_only`, which is **false** for a pure pair
439/// — so `apply_pair` refused every pair plan it was handed, and eleven cases failed
440/// with "this plan mixes a relationship with field changes" on a plan that named
441/// nothing but a relationship. A predicate whose name reads as the answer to a
442/// question it answers the *negation* of is a trap, so both predicates are now
443/// named for the path they belong to: `is_field_path` and `is_pair_path`.
444pub fn owns(patch: &IssuePatch) -> bool {
445    crate::bulk::field_apply::is_pair_path(patch)
446}