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}