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}