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}