Skip to main content

tix/storage/
relations.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl Store {
5    /// Create the typed relationship `a --kind--> b`.
6    ///
7    /// Selectors resolve with the same grammar as the selector-taking
8    /// commands. The edge is stored **once**, in the frontmatter of whichever
9    /// issue owns the canonical edge — the `from` side for a directional kind,
10    /// and the lower-numbered side for `related`, which is order-normalized so
11    /// both spellings of an association are one edge. No back-reference is
12    /// written on the other issue: that symmetry was the retired model.
13    ///
14    /// Validation runs through the service first, so a self-link, a missing
15    /// target, a conflicting kind, or a cycle is refused **before any file is
16    /// touched** (SC-005). Re-asserting an edge that already exists is an
17    /// idempotent no-op: no file is written and no History event is appended.
18    pub fn link(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
19        let issue_a = self.resolve_issue(a)?;
20        let issue_b = self.resolve_issue(b)?;
21        // The graph borrows the loaded issues, so both are built here and stay
22        // alive until this function returns. The service is the only place
23        // relationship semantics live (C-003).
24        let issues = self.list_all()?;
25        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
26        let done_column = self.relationship_done_column();
27        let graph = RelationshipGraph::new(&views, &done_column);
28
29        let verdict = graph.validate_create(issue_a.number, issue_b.number, kind);
30        if verdict.blocking() {
31            return Err(relationship_error(&verdict.errors));
32        }
33
34        // Idempotency goes through the service's own lookup, so an inverse
35        // spelling or a non-canonical stored order still matches.
36        if let Some(existing) = graph.find_canonical(issue_a.number, issue_b.number, kind) {
37            // Nothing is written and no History event is appended. The **real
38            // row** is reported rather than a synthetic one: the contract defines
39            // the output as the accepted relationship, and an empty id is not
40            // addressable by `edit_relation`/`remove_relation`, so returning one
41            // hands the caller a dangling reference (F15).
42            return Ok(RelationshipOutcome {
43                written: None,
44                relationship: existing.clone(),
45                changed: false,
46            });
47        }
48
49        let from = crate::id::canonical_ref(&issue_a);
50        let to = crate::id::canonical_ref(&issue_b);
51        let canonical = CanonicalEdge::new(from, to, kind);
52        let mut owner = self.get(IssueSelector::Ref(canonical.from))?;
53        let target_title = self
54            .get(IssueSelector::Ref(canonical.to))
55            .map(|i| i.title)
56            .unwrap_or_default();
57        let mut relationship =
58            Relationship::new(canonical.from, canonical.to, canonical.kind, &target_title);
59        relationship.refresh_display_for(issue_b.number, &target_title);
60        let detail = format!("created {}: {}", relationship.id, relationship.display);
61        owner.relationships.push(relationship.clone());
62        owner.history.append_now(
63            &actor_identity(),
64            HistoryEventKind::RelationshipAdded,
65            &detail,
66        );
67        owner.bump_updated();
68        owner.filename = self.update(&owner.filename, &owner)?;
69
70        Ok(RelationshipOutcome {
71            written: Some(owner),
72            relationship,
73            changed: true,
74        })
75    }
76
77    /// Remove the typed relationship `a --kind--> b`.
78    ///
79    /// Removing an edge that is not there is an idempotent no-op: no file is
80    /// written and no History event is appended.
81    pub fn unlink(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
82        let canonical = self.canonical_request(a, b, kind)?;
83        let stored = self.find_stored_rows(&canonical)?;
84
85        if stored.is_empty() {
86            // Idempotent no-op: nothing is written and no History event is
87            // appended. The reported relationship is the canonical form the
88            // caller asked about.
89            return Ok(RelationshipOutcome {
90                written: None,
91                relationship: canonical_placeholder(&canonical),
92                changed: false,
93            });
94        }
95
96        // Remove every row matching the canonical edge, wherever it is stored,
97        // and record **one** History event for the accepted mutation. Redundant
98        // rows are one invalid condition to clean up, not N mutations
99        // (contract §6: one event per accepted mutation).
100        let mut by_file: HashMap<String, Vec<Relationship>> = HashMap::new();
101        for (filename, rel) in stored {
102            by_file.entry(filename).or_default().push(rel);
103        }
104
105        let actor = actor_identity();
106        // Ascending filename, for the same reproducibility reason as above.
107        let mut file_order: Vec<String> = by_file.keys().cloned().collect();
108        file_order.sort_unstable();
109
110        // The first row this call removes, captured before anything is written.
111        // M11: a re-scan *after* removal can never match — every matching row is
112        // gone — so a successful unlink always fell through to the empty
113        // placeholder and reported an unaddressable relationship, while `link`'s
114        // no-op path reported a real one. F15 was applied to one path, not both.
115        let mut removed_row: Option<Relationship> = None;
116
117        // M10: the summary is computed **once**, from every row this call
118        // removes, and the same event is recorded on each file that changes.
119        // Appending per file produced two events for one `unlink` when the same
120        // edge was hand-authored into both endpoints, while the comment above
121        // claimed one event per accepted mutation. One unlink is one mutation.
122        let all_removed: Vec<Relationship> = by_file.values().flatten().cloned().collect();
123        let summary = if all_removed.len() == 1 {
124            let one = &all_removed[0];
125            format!("removed {}: {}", one.id, one.display)
126        } else {
127            let kinds: Vec<String> = all_removed.iter().map(|r| r.kind.to_string()).collect();
128            format!(
129                "removed {} relationships ({})",
130                all_removed.len(),
131                kinds.join(", ")
132            )
133        };
134
135        let mut written: Option<Issue> = None;
136        for filename in &file_order {
137            let doomed = &by_file[filename];
138            let mut owner = self.get(IssueSelector::Filename(filename.clone()))?;
139            if removed_row.is_none() {
140                removed_row = doomed.first().cloned();
141            }
142            owner.relationships.retain(|r| r.canonical() != canonical);
143            owner
144                .history
145                .append_now(&actor, HistoryEventKind::RelationshipRemoved, &summary);
146            owner.bump_updated();
147            owner.filename = self.update(&owner.filename, &owner)?;
148            written = Some(owner);
149        }
150
151        // The row that *was* removed, captured above — an empty id is not
152        // addressable by `edit_relation`/`remove_relation`.
153        let relationship = removed_row.unwrap_or_else(|| canonical_placeholder(&canonical));
154
155        Ok(RelationshipOutcome {
156            written,
157            relationship,
158            changed: true,
159        })
160    }
161
162    /// Retarget and/or re-kind the relationship with this stable id.
163    ///
164    /// The id is generated once and **survives** the edit, so a relationship a
165    /// user has already referenced elsewhere does not change identity when its
166    /// target moves (contract §1). `new_to` and `new_kind` are both optional and
167    /// naming neither is the identity edit: nothing is written and no History
168    /// event is appended.
169    ///
170    /// Validation goes through [`RelationshipGraph::validate_edit`], so an edit
171    /// that would self-link, point at a missing issue, duplicate an existing
172    /// edge, or close a cycle is refused **before any file is touched**
173    /// (SC-005). An edit may change the *kind* but never the source: the source
174    /// is the edge's owner, and moving it would mean deleting a relationship and
175    /// creating another, which is what `remove` plus `link` is for.
176    pub fn edit_relation(
177        &self,
178        id: &str,
179        new_to: Option<u32>,
180        new_kind: Option<RelationKind>,
181    ) -> Result<RelationshipOutcome> {
182        let issues = self.list_all()?;
183        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
184        let done_column = self.relationship_done_column();
185        let graph = RelationshipGraph::new(&views, &done_column);
186
187        let Some(existing) = graph.find_relationship(id) else {
188            // An id that does not resolve is a named diagnostic, not a bare
189            // `NotFound`: the user passed a well-formed argument, and what is
190            // missing is a relationship, not an issue.
191            let mut verdict = crate::relationships::RelationshipValidationResult::clean();
192            verdict.errors.push(Diagnostic::unknown_relation(id));
193            return Err(relationship_error(&verdict.errors));
194        };
195
196        let verdict = graph.validate_edit(id, new_to, new_kind);
197        if verdict.blocking() {
198            return Err(relationship_error(&verdict.errors));
199        }
200
201        let to = if let Some(number) = new_to {
202            crate::id::canonical_ref(&self.get(IssueSelector::Number(number))?)
203        } else {
204            existing.to
205        };
206        let kind = new_kind.unwrap_or(existing.kind);
207        let old_canonical = existing.canonical();
208
209        // The new form has to be **canonicalized before anything else is derived
210        // from it**, not merely validated. `--type blocked_by` and re-kinding
211        // `related` change which issue owns the edge *and* which issue it points
212        // at: `A --blocked_by--> B` is stored as `B blocks A`, so the canonical
213        // target is the authored `from`, not the authored `to`.
214        //
215        // Deriving the display name from the *authored* target is what produced
216        // `blocks #2 Issue A` for a `2 duplicates 0` edge re-kinded to
217        // `blocked_by`: right endpoints, wrong title, and a stored row that lies
218        // about the issue it names. Canonicalize first, then read the title off
219        // the canonical target.
220        let requested = CanonicalEdge::new(existing.from, to, kind);
221        let mut updated = existing.clone();
222        updated.from = requested.from;
223        updated.to = requested.to;
224        updated.kind = requested.kind;
225
226        // The target's *current* title, from the canonical target. A target that
227        // does not resolve would otherwise store an edge with an empty display
228        // name, so this stays lenient and the row carries what it can.
229        let target_title = self
230            .get(IssueSelector::Ref(updated.to))
231            .map(|i| i.title)
232            .unwrap_or_default();
233        let target_number = self
234            .get(IssueSelector::Ref(updated.to))
235            .map(|issue| issue.number)
236            .unwrap_or_default();
237        updated.refresh_display_for(target_number, &target_title);
238        let new_canonical = updated.canonical();
239
240        // No-op detection is on the *resolved* edge, not on the arguments: an
241        // edit naming its own current target and kind is the identity, and so is
242        // an edit that only re-renders the same display.
243        if old_canonical == new_canonical && updated.display == existing.display {
244            return Ok(RelationshipOutcome {
245                written: None,
246                relationship: existing.clone(),
247                changed: false,
248            });
249        }
250
251        let actor = actor_identity();
252        let detail = format!("{} → {}: {}", existing.display, updated.display, updated.id);
253
254        // Remove the old row from every file that holds it, without recording
255        // a `relationship_removed` event: this is one edit, not a delete plus a
256        // create. Only the canonical source is written when ownership does not
257        // move, so the common case stays a single-file write.
258        // Removal and insertion are **separate passes**, because the canonical
259        // source of the new form is not necessarily a file that held the old
260        // one. Re-kinding can move ownership — `related` is order-normalised and
261        // `blocked_by` swaps the endpoints — and when it does, a loop that both
262        // removes and re-adds deletes the row from its old owner and never
263        // writes it to the new one. The relationship is then destroyed while the
264        // call reports success, and a `blocked_by` re-kind reports a `blocks`
265        // edge that does not exist, so every derived read is wrong the same way.
266        //
267        // Two passes make that shape unrepresentable: the first only ever
268        // removes, the second only ever adds.
269        let mut removed_anywhere = false;
270        for issue in self.list_all()? {
271            if !issue
272                .relationships
273                .iter()
274                .any(|r| r.id == id && r.canonical() == old_canonical)
275            {
276                continue;
277            }
278            let mut owner = self.get(IssueSelector::Filename(issue.filename.clone()))?;
279            owner
280                .relationships
281                .retain(|r| !(r.id == id && r.canonical() == old_canonical));
282            removed_anywhere = true;
283            owner.bump_updated();
284            owner.filename = self.update(&owner.filename, &owner)?;
285        }
286        debug_assert!(removed_anywhere, "graph found the row, the scan did not");
287
288        // The new canonical source carries the row and the History event. It may
289        // be the file just written, a different file, or one the old form never
290        // touched, so all three cases are written here rather than inside the
291        // removal loop.
292        let mut new_owner = self.get(IssueSelector::Ref(new_canonical.from))?;
293        new_owner.relationships.push(updated.clone());
294        new_owner
295            .history
296            .append_now(&actor, HistoryEventKind::RelationshipRetargeted, &detail);
297        new_owner.bump_updated();
298        new_owner.filename = self.update(&new_owner.filename, &new_owner)?;
299
300        Ok(RelationshipOutcome {
301            written: Some(self.get(IssueSelector::Ref(new_canonical.from))?),
302            relationship: updated,
303            changed: true,
304        })
305    }
306
307    /// Remove the relationship with this stable id.
308    ///
309    /// An id that is not stored is an **idempotent no-op**: no file is written
310    /// and no History event is appended, exactly as [`Store::unlink`] treats an
311    /// absent edge. `tix link remove` is therefore safe to retry.
312    pub fn remove_relation(&self, id: &str) -> Result<RelationshipOutcome> {
313        let stored = self.find_stored_rows_by_id(id)?;
314        if stored.is_empty() {
315            // Reported as a no-op rather than an error, and the reported
316            // relationship carries the id the caller asked about so the outcome
317            // is addressable in the message.
318            return Ok(RelationshipOutcome {
319                written: None,
320                relationship: Relationship {
321                    id: id.to_string(),
322                    ..canonical_placeholder(&CanonicalEdge::new(0, 0, RelationKind::Related))
323                },
324                changed: false,
325            });
326        }
327
328        let canonical = stored[0].1.canonical();
329        // Reuse the edge-keyed removal so both spellings of a remove stay one
330        // implementation, including redundant-row cleanup and the single
331        // History event per accepted mutation.
332        let from = canonical.from.to_string();
333        let to = canonical.to.to_string();
334        self.unlink(&from, &to, canonical.kind)
335    }
336
337    /// Every stored row with this id, paired with the store-relative filename
338    /// it lives in.
339    ///
340    /// Ids are unique per relationship, but a hand-edited file can hold the same
341    /// id twice, so this returns all of them and the caller removes all of them.
342    pub(in crate::storage) fn find_stored_rows_by_id(
343        &self,
344        id: &str,
345    ) -> Result<Vec<(String, Relationship)>> {
346        let mut found = Vec::new();
347        for issue in self.list_all()? {
348            for rel in &issue.relationships {
349                if rel.id == id {
350                    found.push((issue.filename.clone(), rel.clone()));
351                }
352            }
353        }
354        Ok(found)
355    }
356
357    /// The issue set plus the board's configured completion column, for
358    /// building a [`RelationshipGraph`] over the whole store.
359    ///
360    /// The issue set is returned **alongside** the column rather than as a
361    /// finished graph because the graph borrows the issues: a caller that wants
362    /// three derived reads (`relationships_for`, `blocked_state`,
363    /// `dependency_chain`) over one snapshot would otherwise pay three
364    /// separate `O(V + E)` builds and risk the three disagreeing.
365    ///
366    /// This is the single place a read surface obtains its graph, so no surface
367    /// can reach past the service to re-derive direction handling, cycle
368    /// detection, or blocked state (C-003).
369    ///
370    /// Reads the **active** set ([`Store::list`]): it is the graph a default
371    /// view renders — `tix list --show relationships`, `tix relationship list`
372    /// — so archived issues are deliberately absent from it (issue #1492).
373    /// Per-issue lookups that must still resolve an archived issue go through
374    /// [`Store::relationships_for`], which reads [`Store::list_all`].
375    pub fn graph_source(&self) -> Result<(Vec<Issue>, String)> {
376        Ok((self.list()?, self.relationship_done_column()))
377    }
378
379    /// Resolve a requested pair into the canonical edge it refers to, without
380    /// requiring the *target* to exist.
381    ///
382    /// A dangling edge is a diagnosed, expected condition (`data-model.md` §4,
383    /// contract §7), so the removal path must still be able to reach it. Both
384    /// selectors are therefore resolved leniently: a selector that fails to
385    /// resolve is accepted if it is a bare issue number, because that is enough
386    /// to canonicalize the request. Anything else is still `NotFound`.
387    pub(in crate::storage) fn canonical_request(
388        &self,
389        a: &str,
390        b: &str,
391        kind: RelationKind,
392    ) -> Result<CanonicalEdge> {
393        let lenient = |sel: &str| -> Result<crate::id::IssueRef> {
394            match self.resolve_issue(sel) {
395                Ok(issue) => Ok(crate::id::canonical_ref(&issue)),
396                Err(e) => sel
397                    .trim()
398                    .strip_prefix('#')
399                    .unwrap_or_else(|| sel.trim())
400                    .parse::<u32>()
401                    .map(crate::id::IssueRef::Number)
402                    .map_err(|_| e),
403            }
404        };
405        let from = lenient(a)?;
406        let to = lenient(b)?;
407        Ok(CanonicalEdge::new(from, to, kind))
408    }
409
410    /// Every stored row matching `canonical`, paired with the store-relative
411    /// filename it actually lives in.
412    ///
413    /// This is the single answer to "where is this edge stored", and it is the
414    /// reason the three entry points no longer disagree. It matches on
415    /// [`Relationship::canonical`] — the service's own normalization — rather
416    /// than comparing authored fields, so a row is found no matter which file
417    /// holds it or which endpoint order it was written in. Two defects came
418    /// from re-deriving that comparison locally: a hand-authored
419    /// non-canonically-ordered `related` row was un-removable, and a row
420    /// persisted with the input-only `blocked_by` spelling was un-removable too.
421    ///
422    /// Cost is one `O(V + E)` pass, the same order as the validation the write
423    /// path already performs. Correctness of "which file holds this edge" is
424    /// worth it; a wrong answer here silently deletes nothing.
425    pub(in crate::storage) fn find_stored_rows(
426        &self,
427        canonical: &CanonicalEdge,
428    ) -> Result<Vec<(String, Relationship)>> {
429        let mut found = Vec::new();
430        for issue in self.list_all()? {
431            for rel in &issue.relationships {
432                if rel.canonical() == *canonical {
433                    found.push((issue.filename.clone(), rel.clone()));
434                }
435            }
436        }
437        Ok(found)
438    }
439
440    /// Make the issue's relationship set exactly `targets`, with `kind`.
441    ///
442    /// A typed relationship is stored **once, in its canonical source's file**.
443    /// For a directional kind that is the declaring issue; for `related`, which
444    /// is order-normalized, it is the lower-numbered of the pair. So this method
445    /// may have to write more than one file: an edge requested from an issue
446    /// that is not its canonical source belongs to the other issue's frontmatter.
447    /// That is the same ownership rule [`Store::link`] already follows, and it
448    /// is what "stored once, no mirrored edge" requires.
449    ///
450    /// Every target number must already resolve (`NotFound` otherwise) and a
451    /// self-link is refused — both before anything is written (SC-005).
452    /// Relationships that survive keep their stable id and their History
453    /// entries; removals are recorded.
454    #[allow(clippy::too_many_lines)]
455    pub fn set_relationships(
456        &self,
457        filename: &str,
458        targets: &[(u32, RelationKind)],
459    ) -> Result<Issue> {
460        let issue = self.get(IssueSelector::Filename(filename.to_string()))?;
461        let own = issue.number;
462        let own_ref = crate::id::canonical_ref(&issue);
463        let actor = actor_identity();
464
465        // Validate every target up front, before any mutation (SC-005).
466        let mut wanted: Vec<CanonicalEdge> = Vec::with_capacity(targets.len());
467        for (target, kind) in targets {
468            if *target == own {
469                return Err(TixError::InvalidInput(format!(
470                    "issue #{own} cannot relate to itself"
471                )));
472            }
473            let target_issue = self.get(IssueSelector::Number(*target))?;
474            let target_ref = crate::id::canonical_ref(&target_issue);
475            let canonical = CanonicalEdge::new(own_ref, target_ref, *kind);
476            // Identical canonical triples collapse, so asking for the same edge
477            // twice is not two writes and not a self-conflict.
478            if !wanted.contains(&canonical) {
479                wanted.push(canonical);
480            }
481        }
482
483        // Every wanted edge is validated **through the service** before the
484        // first write, exactly as `link` does. This used to re-implement
485        // existence and the self-link rule locally and skip the rest, so with
486        // `1 blocks 2` already on disk it would happily store `2 blocks 1` — a
487        // reciprocal pair that `link` rejects one line earlier and that
488        // contract §3 rules 3 and 4 require be refused. Re-deriving part of the
489        // rule set is also the boundary breach C-003 exists to prevent, and it
490        // becomes user-reachable the moment WP03 wires `--type`.
491        {
492            let issues = self.list_all()?;
493            let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
494            let done_column = self.relationship_done_column();
495            let graph = RelationshipGraph::new(&views, &done_column);
496            for canonical in &wanted {
497                let verdict =
498                    graph.validate_create_ref(canonical.from, canonical.to, canonical.kind);
499                if verdict.blocking() {
500                    return Err(relationship_error(&verdict.errors));
501                }
502            }
503        }
504
505        // Remove: every existing relationship this issue owns that is not
506        // wanted, grouped by the file that actually stores it.
507        //
508        // "Owns" has two cases, and getting this wrong is a silent data bug:
509        //
510        //  - The canonical `from` is this issue. True for every directional
511        //    kind, and for `related` whenever this issue has the lower number.
512        //  - The canonical `to` is this issue **and** the kind is
513        //    non-directional. A `related` association belongs to both
514        //    endpoints even though it is stored once, in the lower-numbered
515        //    issue's file. `tix edit 5 --links ""` has to remove a
516        //    `5 related 3` edge even though it lives in #3's frontmatter and
517        //    #5's own file is empty.
518        //
519        // An *inbound directional* edge is deliberately excluded: if #3 blocks
520        // #5, that edge is #3's declaration, and #5 clearing its own
521        // relationship set must not silently rewrite #3's dependency graph.
522        let issues = self.list_all()?;
523        let mut removals: HashMap<crate::id::IssueRef, Vec<Relationship>> = HashMap::new();
524        for candidate in &issues {
525            for rel in &candidate.relationships {
526                let canonical = rel.canonical();
527                let owned_by_us = canonical.from == own_ref
528                    || (canonical.to == own_ref && !canonical.kind.is_directional());
529                if owned_by_us && !wanted.contains(&canonical) {
530                    removals
531                        .entry(canonical.from)
532                        .or_default()
533                        .push(rel.clone());
534                }
535            }
536        }
537
538        // Add: each wanted edge goes into its canonical source's file. One
539        // already present is left alone, so the stable id survives.
540        let mut additions: HashMap<crate::id::IssueRef, Vec<(CanonicalEdge, String)>> =
541            HashMap::new();
542        for canonical in &wanted {
543            let owner = match self.get(IssueSelector::Ref(canonical.from)) {
544                Ok(owner) => owner,
545                Err(TixError::NotFound(_)) => continue,
546                Err(e) => return Err(e),
547            };
548            if owner
549                .relationships
550                .iter()
551                .any(|r| r.canonical() == *canonical)
552            {
553                continue;
554            }
555            let target_title = self
556                .get(IssueSelector::Ref(canonical.to))
557                .map(|i| i.title)
558                .unwrap_or_default();
559            additions
560                .entry(canonical.from)
561                .or_default()
562                .push((*canonical, target_title));
563        }
564
565        if removals.is_empty() && additions.is_empty() {
566            return Ok(issue);
567        }
568
569        // Apply removals, then additions. This can write **N** files — the
570        // removals map is keyed by whichever file actually holds each row, and
571        // an order-normalized `related` edge is held by the lower-numbered
572        // issue, not the one named by `filename`. A failure partway through
573        // would therefore leave the store half-mutated, so every file's
574        // pre-write contents are captured on the way through and restored if a
575        // later write fails (SC-005: no file is left partially rewritten).
576        //
577        // The restore is best-effort per file and the original error is
578        // returned either way: a rollback that itself fails must not mask the
579        // cause.
580        // Every file's pre-write contents are captured on the way through and
581        // restored if a later write in the same call fails, so a refused
582        // mutation leaves nothing behind (SC-005). The restore is best-effort
583        // per file and the original error is returned either way: a rollback
584        // that itself fails must not mask the cause.
585        // Owners are written in ascending number order. `removals` and
586        // `additions` are `HashMap`s, so iterating them directly makes the write
587        // order — and therefore which files are committed before a failure —
588        // differ between runs of the same input.
589        let mut removal_order: Vec<crate::id::IssueRef> = removals.keys().copied().collect();
590        removal_order.sort_unstable();
591        let mut addition_order: Vec<crate::id::IssueRef> = additions.keys().copied().collect();
592        addition_order.sort_unstable();
593
594        // Write one owner, remembering how to undo it. A nested `fn` rather than
595        // a closure so `undo` and `touched_named` can be passed explicitly
596        // instead of captured, which would make them unborrowable at the
597        // call sites.
598        fn apply(
599            store: &Store,
600            mut owner: Issue,
601            undo: &mut Vec<(PathBuf, String)>,
602            touched_named: &mut bool,
603            named_path: &Path,
604        ) -> Result<()> {
605            let path = store.issues_dir.join(&owner.filename);
606            if path == named_path {
607                *touched_named = true;
608            }
609            if let Ok(previous) = std::fs::read_to_string(&path) {
610                undo.push((path, previous));
611            }
612            owner.bump_updated();
613            store.update(&owner.filename, &owner).map(|_| ())
614        }
615
616        let named_path = self.issues_dir.join(&issue.filename);
617        let mut undo: Vec<(PathBuf, String)> = Vec::new();
618        let mut touched_named = false;
619
620        let applied = (|| -> Result<()> {
621            for owner_ref in &removal_order {
622                let doomed = &removals[owner_ref];
623                let mut owner = self.get(IssueSelector::Ref(*owner_ref))?;
624                let mut changed = false;
625                for rel in doomed {
626                    let canonical = rel.canonical();
627                    let before = owner.relationships.len();
628                    owner.relationships.retain(|r| r.canonical() != canonical);
629                    if owner.relationships.len() != before {
630                        let detail = format!("removed {}: {}", rel.id, rel.display);
631                        owner.history.append_now(
632                            &actor,
633                            HistoryEventKind::RelationshipRemoved,
634                            &detail,
635                        );
636                        changed = true;
637                    }
638                }
639                if changed {
640                    apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
641                }
642            }
643
644            for owner_ref in &addition_order {
645                let wanted_here = &additions[owner_ref];
646                let mut owner = self.get(IssueSelector::Ref(*owner_ref))?;
647                for (canonical, target_title) in wanted_here {
648                    let rel = Relationship::new(
649                        canonical.from,
650                        canonical.to,
651                        canonical.kind,
652                        target_title,
653                    );
654                    let detail = format!("created {}: {}", rel.id, rel.display);
655                    owner
656                        .history
657                        .append_now(&actor, HistoryEventKind::RelationshipAdded, &detail);
658                    owner.relationships.push(rel);
659                }
660                apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
661            }
662
663            // F16. The named issue's own frontmatter can be untouched while its
664            // relationship set changed, because an order-normalized `related`
665            // edge is stored in the *other* endpoint's file. `tix edit 2 --links
666            // 1` then left #2 byte-identical with a stale `updated`, so the
667            // command appeared to do nothing to the issue it named and the audit
668            // trail lived only on the other endpoint. `updated` tracks the
669            // issue's last mutation and its relationship set did change.
670            //
671            // Inside the rolled-back sequence and through the same `apply`, so
672            // its file is captured for restore like every other. Placed after the
673            // rollback check it was a fresh partial-write hole: a failure here
674            // returned an error with the relationship writes already committed
675            // and nothing left to restore.
676            if !touched_named {
677                let named = self.get(IssueSelector::Filename(filename.to_string()))?;
678                apply(self, named, &mut undo, &mut touched_named, &named_path)?;
679            }
680            Ok(())
681        })();
682
683        if let Err(e) = applied {
684            for (path, previous) in undo.iter().rev() {
685                let _ = atomic_write(&self.issues_dir, path, previous.as_bytes());
686            }
687            return Err(e);
688        }
689
690        self.get(IssueSelector::Filename(filename.to_string()))
691    }
692
693    /// Every relationship touching `number`, as the service resolves it.
694    ///
695    /// **This is what a read surface must render**, not `issue.relationships`.
696    /// A relationship is stored once, in its canonical source's frontmatter, so
697    /// the stored field is a *partial* view of one endpoint: for `2 related 1`
698    /// the edge lives in #1's file, so #2's own `relationships` is empty even
699    /// though #2 plainly has a relationship. Reading the raw field made `tix
700    /// view 2` and the TUI detail pane show nothing where the service reports
701    /// one, which is a user-visible regression against the retired symmetric
702    /// model — the old field was mirrored on both sides by construction.
703    ///
704    /// Routing every surface through one accessor is also what keeps C-003
705    /// honest: derived relationship state is computed in exactly one place.
706    pub fn relationships_for(&self, number: u32) -> Result<Vec<Relationship>> {
707        let issues = self.list_all()?;
708        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
709        let done_column = self.relationship_done_column();
710        let graph = RelationshipGraph::new(&views, &done_column);
711        Ok(graph
712            .relationships_for(number)
713            .into_iter()
714            .cloned()
715            .collect())
716    }
717
718    /// **Every** stored relationship, in deterministic order — the whole graph in
719    /// one pass.
720    ///
721    /// [`Store::relationships_for`] answers the same question for one issue, and
722    /// **each of its calls reads the whole tree and rebuilds the graph**. A caller
723    /// that wants every edge must therefore ask once: an `N`-issue loop over
724    /// `relationships_for` is `N` full scans of `N` files, which at this
725    /// repository's size is millions of file reads for one export. This is the
726    /// accessor for that caller.
727    ///
728    /// Archived issues are included, exactly as [`Store::relationships_for`]
729    /// includes them — the graph is the same one, built the same way, so the two
730    /// agree about which edges exist.
731    pub fn all_relationships(&self) -> Result<Vec<Relationship>> {
732        let issues = self.list_all()?;
733        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
734        let done_column = self.relationship_done_column();
735        let graph = RelationshipGraph::new(&views, &done_column);
736        Ok(graph.all_relationships().to_vec())
737    }
738}