Skip to main content

tix/
relationships.rs

1//! Typed relationship domain service.
2//!
3//! This module is the **single owner** of every relationship semantic in
4//! `tix`: the closed relation vocabulary, stable relationship identity, the
5//! canonical direction convention, adjacency, and all graph validation
6//! (self-link, missing target, conflicting edge, cycle). The CLI, TUI, MCP,
7//! `tix lint`, the move guard, and the sync adapters all call into here —
8//! none of them re-derives direction handling, cycle detection, or blocked
9//! state locally. That is what makes CLI/TUI/MCP parity structural rather
10//! than a convention (charter: "share domain/application logic where
11//! practical").
12//!
13//! # Canonical direction convention
14//!
15//! There is exactly one convention, and it is stated here rather than only in
16//! a test:
17//!
18//! - `blocks` is authored from the **blocking** issue to the **blocked** issue
19//!   and is the **only authoritative stored form** of a blocking relationship.
20//! - `blocked_by` is the explicit inverse **input spelling** of that same
21//!   edge. [`normalize`] rewrites it into the canonical `blocks` edge, so a
22//!   `blocked_by` value is never persisted and a pair is never stored twice:
23//!   `A --blocked_by--> B` and `B --blocks--> A` are the same edge.
24//! - `duplicates` and `related` are authored source → target and never produce
25//!   a mirrored edge. `related` is the only non-directional kind.
26//!
27//! A **reciprocal** blocking pair (`A blocks B` *and* `B blocks A`) is a
28//! two-edge directed cycle and is therefore rejected by the cycle rule. There
29//! is no accept-and-normalize path for it.
30//!
31//! # Closed vocabulary
32//!
33//! Exactly four kinds ship: `blocks`, `blocked_by`, `duplicates`, `related`
34//! (decision `01M3BXC5P4MK0BYECA3K1B9Z82`). The set is **compiled in** — this
35//! module adds no `tix.toml` relation-type configuration key, and an unknown
36//! kind is a validation error, never a free-form string and never a coerced
37//! default.
38//!
39//! # Blocked-state resolution predicate
40//!
41//! [`RelationshipGraph::blocked_state`] is the only place "is this blocker
42//! still open?" is answered. An inbound `blocks` edge from blocker `X` to
43//! issue `Y` counts **iff** `X` resolves to an existing issue **and** `X`'s
44//! status is not the board's configured `done_column`. A dangling edge whose
45//! blocker no longer exists is reported as `missing_target` by validation but
46//! is **excluded** here, so invalid data can never permanently freeze an
47//! issue. Completion is resolved from configuration, never by matching the
48//! literal string `done`; workflow *category* semantics are out of scope
49//! (source issue #183).
50//!
51//! # Breaking replacement
52//!
53//! The retired `links:` frontmatter key is a **validation result**
54//! ([`DiagnosticKind::RetiredLinksKey`]), never a parse fallback. Nothing in
55//! this module reads legacy link values as typed data (C-001).
56//!
57//! # Complexity
58//!
59//! [`RelationshipGraph::validate_snapshot`] is a single `O(V + E)` pass and
60//! never returns `Err` — it yields a [`RelationshipValidationResult`] so
61//! `tix lint` can call it directly. Cycle detection and blocked-state
62//! derivation share one adjacency build, so a surface that shows both does not
63//! pay twice (NFR-001). Traversal is iterative throughout: a 10,000-node chain
64//! must not overflow the stack.
65//!
66//! # Scope of this module
67//!
68//! This is the domain layer only. Of the operations in
69//! `contracts/relationship-service.md` §1, it owns the *computation* half:
70//!
71//! | Contract operation | Provided here | Supplied by |
72//! |---|---|---|
73//! | `validate_snapshot` | yes | — |
74//! | `dependency_chain` | yes | — |
75//! | `blocked_state` | yes | — |
76//! | `list_relations` | yes, as `relationships_for` / `blocks` / `blocked_by` / `duplicates_of` / `related_to` | — |
77//! | "are these the same relationship?" | yes, as `find_canonical` | — |
78//! | `create_relation` | validation + lookup only: `validate_create`, `find_canonical`, `normalize` | write path: WP02 `Store`, WP03 CLI |
79//! | `edit_relation` | validation only: `validate_edit` | write path: WP02 `Store`, WP03 CLI |
80//! | `remove_relation` | lookup only: `find_relationship` | write path: WP02 `Store`, WP03 CLI |
81//!
82//! Nothing here reads or writes a file, so the write half cannot be added
83//! without the storage seam. `validate_*` returns a verdict; persisting it is
84//! the caller's job, and it must not write anything at all when the verdict is
85//! blocking (contract §4).
86
87use std::collections::{HashMap, HashSet, VecDeque};
88use std::fmt;
89use std::str::FromStr;
90
91use serde::{Deserialize, Serialize};
92
93// ---------------------------------------------------------------------------
94// T001 — the closed vocabulary and the typed edge
95// ---------------------------------------------------------------------------
96
97/// The closed vocabulary of typed relations.
98///
99/// Exactly four kinds ship in this mission (decision
100/// `01M3BXC5P4MK0BYECA3K1B9Z82`). The set is compiled in — there is no
101/// configuration key — and an unknown kind is rejected rather than coerced.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum RelationKind {
105    /// The source issue blocks the target issue. The canonical stored form of
106    /// a blocking relationship.
107    Blocks,
108    /// The source issue is blocked by the target issue. An **input spelling
109    /// only**: it normalizes into the equivalent [`RelationKind::Blocks`] edge
110    /// and is never persisted.
111    BlockedBy,
112    /// The source issue duplicates the target issue. No blocked-state effect.
113    Duplicates,
114    /// Explicit non-directional association. Stored once, never mirrored, and
115    /// the only non-directional kind.
116    Related,
117}
118
119impl RelationKind {
120    /// Every kind in the closed vocabulary, in canonical spell order.
121    pub const ALL: [RelationKind; 4] = [
122        RelationKind::Blocks,
123        RelationKind::BlockedBy,
124        RelationKind::Duplicates,
125        RelationKind::Related,
126    ];
127
128    /// The canonical lowercase spelling used in files and CLI output.
129    pub fn as_str(self) -> &'static str {
130        match self {
131            RelationKind::Blocks => "blocks",
132            RelationKind::BlockedBy => "blocked_by",
133            RelationKind::Duplicates => "duplicates",
134            RelationKind::Related => "related",
135        }
136    }
137
138    /// Whether the kind has a direction. False only for [`RelationKind::Related`].
139    pub fn is_directional(self) -> bool {
140        !matches!(self, RelationKind::Related)
141    }
142
143    /// The kind that describes the same edge seen from the other endpoint.
144    ///
145    /// `Blocks` ↔ `BlockedBy`; `Duplicates` and `Related` have no inverse
146    /// because neither mirrors.
147    pub fn inverse(self) -> Option<Self> {
148        match self {
149            RelationKind::Blocks => Some(RelationKind::BlockedBy),
150            RelationKind::BlockedBy => Some(RelationKind::Blocks),
151            RelationKind::Duplicates | RelationKind::Related => None,
152        }
153    }
154
155    /// The kind this one normalizes to for storage, plus whether the
156    /// endpoints must be swapped to reach it.
157    ///
158    /// `blocked_by` is the only kind that swaps: `A blocked_by B` is stored as
159    /// `B blocks A`.
160    fn canonical(self) -> (Self, bool) {
161        match self {
162            RelationKind::BlockedBy => (RelationKind::Blocks, true),
163            other => (other, false),
164        }
165    }
166}
167
168impl fmt::Display for RelationKind {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        f.write_str(self.as_str())
171    }
172}
173
174impl FromStr for RelationKind {
175    type Err = Diagnostic;
176
177    /// Parse a canonical kind spelling, rejecting anything outside the closed
178    /// vocabulary with an [`DiagnosticKind::UnknownKind`] diagnostic. An
179    /// unknown kind is never coerced to `related`.
180    fn from_str(s: &str) -> Result<Self, Self::Err> {
181        match s {
182            "blocks" => Ok(RelationKind::Blocks),
183            "blocked_by" => Ok(RelationKind::BlockedBy),
184            "duplicates" => Ok(RelationKind::Duplicates),
185            "related" => Ok(RelationKind::Related),
186            other => Err(Diagnostic::unknown_kind(
187                "kind",
188                format!(
189                    "unknown relation kind `{other}`; expected one of blocks, blocked_by, duplicates, related"
190                ),
191            )),
192        }
193    }
194}
195
196/// A stable relationship identifier (`rel_` + 26 Crockford base32 characters,
197/// ULID-shaped: a 48-bit millisecond timestamp followed by 80 random bits).
198///
199/// The id is generated once at creation and survives retargeting, renaming,
200/// and export/import, so it is persisted rather than regenerated.
201pub type RelationshipId = String;
202
203/// A single typed edge between two issues.
204///
205/// Field names and invariants come from `data-model.md` §2. The `from`/`to`
206/// fields are issue numbers ([`crate::model::Issue::number`], never reused).
207#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
208pub struct Relationship {
209    /// Stable identifier; survives retarget, rename, export, and import.
210    #[serde(default)]
211    pub id: RelationshipId,
212    /// The issue that declares the edge.
213    pub from: u32,
214    /// The issue the edge points at.
215    pub to: u32,
216    /// The relation kind. A persisted edge never carries
217    /// [`RelationKind::BlockedBy`] — it is normalized to
218    /// [`RelationKind::Blocks`] before storage.
219    pub kind: RelationKind,
220    /// Human-readable display name, e.g. `blocks #42 Fix flaky test`.
221    /// Recomputed from the target title by [`Relationship::refresh_display`];
222    /// omitted from the serialized form while empty.
223    #[serde(default, skip_serializing_if = "String::is_empty")]
224    pub display: String,
225}
226
227impl Relationship {
228    /// Build a relationship with a freshly generated stable id and the
229    /// canonical display name for `target_title`.
230    pub fn new(from: u32, to: u32, kind: RelationKind, target_title: &str) -> Self {
231        let canonical = CanonicalEdge::new(from, to, kind);
232        Self {
233            id: generate_relationship_id(),
234            from: canonical.from,
235            to: canonical.to,
236            kind: canonical.kind,
237            display: display_name(canonical.kind, canonical.to, target_title),
238        }
239    }
240
241    /// Rebuild [`Relationship::display`] from the current kind and the target
242    /// issue's title, leaving the stable id untouched.
243    pub fn refresh_display(&mut self, target_title: &str) {
244        self.display = display_name(self.kind, self.to, target_title);
245    }
246
247    /// The canonical form of this edge: the direction convention applied, so a
248    /// `blocked_by` edge comes back with swapped endpoints and kind `blocks`,
249    /// and a `related` edge comes back in `(min, max)` order.
250    pub fn canonical(&self) -> CanonicalEdge {
251        CanonicalEdge::new(self.from, self.to, self.kind)
252    }
253}
254
255/// Whether landing on an edge that already exists is an error, or the
256/// idempotent no-op that `data-model.md` §2 requires of a re-applied edit.
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258enum Duplicate {
259    /// Re-asserting an edge that already exists is fine. This is the `create`
260    /// path: running `tix link A B --type blocks` twice must not fail.
261    Allowed,
262    /// Landing on a triple another relationship already occupies is an error,
263    /// because the edit would leave two stored edges for one relationship.
264    /// This is the `edit` path.
265    Rejected,
266}
267
268/// The human-readable display name for an edge, e.g. `blocks #42 Fix flaky test`.
269pub fn display_name(kind: RelationKind, target: u32, target_title: &str) -> String {
270    if target_title.trim().is_empty() {
271        format!("{} #{target}", kind.as_str())
272    } else {
273        format!("{} #{target} {}", kind.as_str(), target_title.trim())
274    }
275}
276
277// ---------------------------------------------------------------------------
278// T002 — canonical direction convention and inverse normalization
279// ---------------------------------------------------------------------------
280
281/// An edge in canonical form: the direction convention already applied.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
283pub struct CanonicalEdge {
284    /// The blocking side for a blocking edge; the source otherwise.
285    pub from: u32,
286    /// The blocked side for a blocking edge; the target otherwise.
287    pub to: u32,
288    /// Always [`RelationKind::Blocks`] for a blocking edge, never
289    /// [`RelationKind::BlockedBy`].
290    pub kind: RelationKind,
291}
292
293impl CanonicalEdge {
294    /// Apply the direction convention to an authored edge.
295    ///
296    /// Two kinds need their endpoints rearranged, for different reasons:
297    ///
298    /// - [`RelationKind::BlockedBy`] **swaps**. It is the inverse spelling of
299    ///   a blocking edge, so `A blocked_by B` is stored as `B blocks A`.
300    /// - [`RelationKind::Related`] is **order-normalized** to `(min, max)`. It
301    ///   is the only non-directional kind, so the endpoint order carries no
302    ///   meaning; fixing it makes `1 related 2` and `2 related 1` the same
303    ///   stored edge, which is what `data-model.md` §1 requires ("stored
304    ///   once; no mirrored edge"). The visible consequence is that an edge
305    ///   authored as `2 related 1` displays as `related #1 …`; the orientation
306    ///   was arbitrary, so this is a canonical choice, not a loss.
307    ///
308    /// [`RelationKind::Duplicates`] is directional and is deliberately **not**
309    /// reordered — "2 duplicates 1" is not the same claim as "1 duplicates 2".
310    pub fn new(from: u32, to: u32, kind: RelationKind) -> Self {
311        let (kind, swap) = kind.canonical();
312        // Both rearrangements end in a swap; they differ only in *when* they
313        // apply. `blocked_by` always swaps, `related` swaps only when that puts
314        // the pair in (min, max) order.
315        if swap || (!kind.is_directional() && from > to) {
316            Self {
317                from: to,
318                to: from,
319                kind,
320            }
321        } else {
322            Self { from, to, kind }
323        }
324    }
325
326    /// Whether both endpoints resolve to the same issue.
327    pub fn is_self_link(self) -> bool {
328        self.from == self.to
329    }
330}
331
332/// Apply the canonical direction convention to an authored edge.
333///
334/// `A --blocked_by--> B` and `B --blocks--> A` both normalize to the same
335/// canonical edge, which is why a pair can never be stored twice.
336pub fn normalize(from: u32, to: u32, kind: RelationKind) -> CanonicalEdge {
337    CanonicalEdge::new(from, to, kind)
338}
339
340/// Normalize a batch of authored edges into a deduplicated, deterministically
341/// ordered canonical set.
342///
343/// This is the property that makes `(from, to, kind)` uniqueness testable: two
344/// spellings of the same edge collapse to one entry, and the output is sorted
345/// so repeated calls on the same input are byte-identical.
346pub fn normalize_all<I>(authored: I) -> Vec<CanonicalEdge>
347where
348    I: IntoIterator<Item = (u32, u32, RelationKind)>,
349{
350    let mut seen: HashSet<CanonicalEdge> = HashSet::new();
351    for (from, to, kind) in authored {
352        seen.insert(CanonicalEdge::new(from, to, kind));
353    }
354    let mut edges: Vec<CanonicalEdge> = seen.into_iter().collect();
355    edges.sort_unstable();
356    edges
357}
358
359// ---------------------------------------------------------------------------
360// T004 — diagnostics and the validation result
361// ---------------------------------------------------------------------------
362
363/// The closed set of relationship validation diagnostics.
364///
365/// The names are authoritative and come from
366/// `contracts/relationship-service.md` §3.
367#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
368#[serde(rename_all = "snake_case")]
369pub enum DiagnosticKind {
370    /// `from == to`; always rejected.
371    SelfLink,
372    /// A selector resolved to no issue.
373    MissingTarget,
374    /// A stored edge contradicts the canonical direction for the same pair.
375    ConflictingEdge,
376    /// An edge would close a directed cycle.
377    Cycle,
378    /// A relation kind outside the closed vocabulary.
379    UnknownKind,
380    /// An edit or remove named a relationship id that does not exist.
381    UnknownRelation,
382    /// Legacy `links:` frontmatter key is still present.
383    RetiredLinksKey,
384}
385
386impl DiagnosticKind {
387    /// The canonical snake_case name used in CLI, TUI, and MCP output.
388    pub fn as_str(self) -> &'static str {
389        match self {
390            DiagnosticKind::SelfLink => "self_link",
391            DiagnosticKind::MissingTarget => "missing_target",
392            DiagnosticKind::ConflictingEdge => "conflicting_edge",
393            DiagnosticKind::Cycle => "cycle",
394            DiagnosticKind::UnknownKind => "unknown_kind",
395            DiagnosticKind::UnknownRelation => "unknown_relation",
396            DiagnosticKind::RetiredLinksKey => "retired_links_key",
397        }
398    }
399}
400
401impl fmt::Display for DiagnosticKind {
402    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
403        f.write_str(self.as_str())
404    }
405}
406
407/// One field-aware relationship diagnostic.
408#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
409pub struct Diagnostic {
410    /// Which rule failed.
411    pub kind: DiagnosticKind,
412    /// The offending field or selector, e.g. `to`, `kind`, or `links`.
413    pub field: String,
414    /// Human-readable explanation, safe to print in CLI output, the TUI, and
415    /// lint.
416    pub message: String,
417    /// The issue this diagnostic is **about**, when there is exactly one.
418    ///
419    /// Added for WP06 T025. `tix lint` has to attribute a diagnostic to a *file*,
420    /// and a file is found by issue number — so without this the only route is to
421    /// read the number back out of `message`, and the message is prose whose shape
422    /// is not a contract. It already is not one: `self_link` opens with `#1 while
423    /// `missing_target` opens with `issue #1`, and `conflicting_edge` names two
424    /// issues. A consumer that assumed either shape would work until the wording
425    /// changed, and would then attribute findings to the wrong file.
426    ///
427    /// `None` where the diagnostic is genuinely not about one issue.
428    ///
429    /// Which is not the same as "not a kind that has a subject". An
430    /// [`DiagnosticKind::UnknownKind`] naming a bare selector and an
431    /// [`DiagnosticKind::UnknownRelation`] naming a relationship id are always
432    /// `None` — but [`DiagnosticKind::ConflictingEdge`] and
433    /// [`DiagnosticKind::Cycle`] are `Some` when they come from
434    /// [`RelationshipGraph::validate_snapshot`] and `None` when they come from
435    /// [`RelationshipGraph::validate_authored`], which builds them with
436    /// [`Diagnostic::new`].
437    ///
438    /// That asymmetry is recorded rather than papered over, because the field
439    /// exists to be a contract and a doc claiming more than the code does is worse
440    /// than no doc. Only `validate_snapshot` reaches lint, so nothing observable
441    /// depends on the untagged half today; if a caller starts reading `issue` on a
442    /// write-path diagnostic, that is the moment to tag those three sites.
443    pub issue: Option<u32>,
444}
445
446impl Diagnostic {
447    /// Build a diagnostic of any kind.
448    pub fn new(kind: DiagnosticKind, field: impl Into<String>, message: impl Into<String>) -> Self {
449        Self {
450            kind,
451            field: field.into(),
452            message: message.into(),
453            issue: None,
454        }
455    }
456
457    /// A diagnostic that is about one specific issue — the form lint needs, since
458    /// it has to name the file.
459    pub fn about(
460        issue: u32,
461        kind: DiagnosticKind,
462        field: impl Into<String>,
463        message: impl Into<String>,
464    ) -> Self {
465        Self {
466            kind,
467            field: field.into(),
468            message: message.into(),
469            issue: Some(issue),
470        }
471    }
472
473    /// A [`DiagnosticKind::SelfLink`] for the given issue.
474    pub fn self_link(issue: u32) -> Self {
475        Self::about(
476            issue,
477            DiagnosticKind::SelfLink,
478            "to",
479            format!("#{issue} cannot block itself: a self-link is not a valid relationship"),
480        )
481    }
482
483    /// A [`DiagnosticKind::MissingTarget`] for an unresolvable selector.
484    pub fn missing_target(issue: u32, selector: impl fmt::Display) -> Self {
485        Self::about(
486            issue,
487            DiagnosticKind::MissingTarget,
488            "to",
489            format!("issue #{issue} references `{selector}`, which resolves to no issue"),
490        )
491    }
492
493    /// A [`DiagnosticKind::UnknownKind`] for a kind outside the vocabulary.
494    pub fn unknown_kind(field: impl Into<String>, message: impl Into<String>) -> Self {
495        Self::new(DiagnosticKind::UnknownKind, field, message)
496    }
497
498    /// A [`DiagnosticKind::UnknownRelation`] for a relationship id with no match.
499    pub fn unknown_relation(id: &str) -> Self {
500        Self::new(
501            DiagnosticKind::UnknownRelation,
502            "id",
503            format!("no relationship with id `{id}`"),
504        )
505    }
506}
507
508impl fmt::Display for Diagnostic {
509    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
510        write!(
511            f,
512            "{} ({}): {}",
513            self.kind.as_str(),
514            self.field,
515            self.message
516        )
517    }
518}
519
520/// The per-field diagnostic set returned by every validation call.
521///
522/// It is a **value**, never an `Err`: `tix lint` needs a value even when
523/// nothing is wrong, and a read-only sweep must not fail fast (FR-001, C-004).
524#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
525pub struct RelationshipValidationResult {
526    /// Every diagnostic found, in a deterministic order.
527    pub errors: Vec<Diagnostic>,
528}
529
530impl RelationshipValidationResult {
531    /// A result carrying no diagnostics.
532    pub fn clean() -> Self {
533        Self::default()
534    }
535
536    /// A result carrying one diagnostic.
537    pub fn with(diagnostic: Diagnostic) -> Self {
538        Self {
539            errors: vec![diagnostic],
540        }
541    }
542
543    /// Whether no rule failed.
544    pub fn is_clean(&self) -> bool {
545        self.errors.is_empty()
546    }
547
548    /// Whether a mutation must be rejected.
549    ///
550    /// Every diagnostic kind is blocking for a write: a partial or
551    /// reinterpreted edge is worse than a refused one, and a legacy `links:`
552    /// key must never be silently dropped by a rewrite (C-001, SC-005).
553    pub fn blocking(&self) -> bool {
554        !self.errors.is_empty()
555    }
556
557    /// Whether any diagnostic of `kind` was reported.
558    pub fn has(&self, kind: DiagnosticKind) -> bool {
559        self.errors.iter().any(|d| d.kind == kind)
560    }
561
562    /// Fold `other` into this result, keeping the combined order deterministic.
563    pub fn merge(&mut self, other: Self) {
564        self.errors.extend(other.errors);
565    }
566
567    /// Sort diagnostics by `(kind, field, message)` so repeated sweeps over the
568    /// same snapshot are byte-comparable across surfaces.
569    pub fn sort_diagnostics(&mut self) {
570        self.errors
571            .sort_by(|a, b| (a.kind, &a.field, &a.message).cmp(&(b.kind, &b.field, &b.message)));
572    }
573}
574
575// ---------------------------------------------------------------------------
576// T003 — the in-memory graph
577// ---------------------------------------------------------------------------
578
579/// The minimal projection of an issue that the relationship service needs.
580///
581/// This is deliberately *not* [`crate::model::Issue`]: the issue model gains
582/// its typed relationship field in a later work package, and keeping the
583/// service on a narrow view means the graph stays filesystem-free and
584/// testable with a plain in-memory list.
585#[derive(Debug, Clone, Copy)]
586pub struct IssueView<'a> {
587    /// The stable issue number (`Issue::number`, never reused).
588    pub number: u32,
589    /// The issue title, used to build display names.
590    pub title: &'a str,
591    /// The board column the issue sits in, compared against `done_column`.
592    pub status: &'a str,
593    /// The issue's stored relationships, in authored file order.
594    pub relationships: &'a [Relationship],
595    /// Whether the source frontmatter still carries a legacy `links:` key.
596    /// The key is reported as a validation result and never read as typed data
597    /// (C-001); this flag is how the storage layer signals it.
598    pub has_retired_links_key: bool,
599    /// The issue's decomposition parent, as an issue number.
600    ///
601    /// Present on the relationship service's view so the **hierarchy** service
602    /// ([`crate::hierarchy`]) reads the same in-memory projection rather than a
603    /// parallel one built by each caller. Two projections of one issue type is how
604    /// two services end up disagreeing about the same snapshot — the divergence
605    /// `contracts/surface-parity.md` §3 exists to prevent.
606    ///
607    /// Not read by the relationship graph. A parent is a decomposition and a
608    /// `blocks` edge is a scheduling dependency; they are separate fields and are
609    /// never combined (C-007).
610    pub parent: Option<u32>,
611}
612
613impl<'a> IssueView<'a> {
614    /// Build an issue view without the retired-key flag.
615    pub fn new(
616        number: u32,
617        title: &'a str,
618        status: &'a str,
619        relationships: &'a [Relationship],
620    ) -> Self {
621        Self {
622            number,
623            title,
624            status,
625            relationships,
626            has_retired_links_key: false,
627            parent: None,
628        }
629    }
630
631    /// Build an issue view, flagging a legacy `links:` key.
632    pub fn with_retired_links_key(mut self, flagged: bool) -> Self {
633        self.has_retired_links_key = flagged;
634        self
635    }
636
637    /// Build an issue view carrying a decomposition parent.
638    pub fn with_parent(mut self, parent: Option<u32>) -> Self {
639        self.parent = parent;
640        self
641    }
642}
643
644// ---------------------------------------------------------------------------
645// The hierarchy service reads the same view (WP01's `HierarchyNode`).
646// ---------------------------------------------------------------------------
647
648impl crate::hierarchy::HierarchyNode for IssueView<'_> {
649    fn number(&self) -> u32 {
650        self.number
651    }
652    fn title(&self) -> &str {
653        self.title
654    }
655    fn status(&self) -> &str {
656        self.status
657    }
658    fn parent_number(&self) -> Option<u32> {
659        self.parent
660    }
661}
662
663/// One node of a dependency chain.
664#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
665pub struct ChainNode {
666    /// The issue number.
667    pub number: u32,
668    /// The issue title at read time.
669    pub title: String,
670}
671
672impl ChainNode {
673    /// Build a chain node.
674    pub fn new(number: u32, title: impl Into<String>) -> Self {
675        Self {
676            number,
677            title: title.into(),
678        }
679    }
680}
681
682/// The transitive blocker chain of an issue (`data-model.md` §3).
683#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
684pub struct DependencyChain {
685    /// The issue the chain was requested for.
686    pub root: u32,
687    /// Ordered node sequences from the root upward toward its blockers.
688    pub paths: Vec<Vec<ChainNode>>,
689    /// Set when the traversal bound was reached, so display can say so rather
690    /// than silently truncating.
691    pub truncated: bool,
692}
693
694impl DependencyChain {
695    /// The distinct issues in the chain, in first-seen order.
696    pub fn nodes(&self) -> Vec<&ChainNode> {
697        let mut seen = HashSet::new();
698        self.paths
699            .iter()
700            .flatten()
701            .filter(|n| seen.insert(n.number))
702            .collect()
703    }
704}
705
706/// The derived blocked condition surfaced by every surface (`data-model.md` §4).
707#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
708pub struct BlockedState {
709    /// At least one open inbound `blocks` edge exists.
710    pub is_blocked: bool,
711    /// The open blockers, ordered by issue number.
712    pub blockers: Vec<ChainNode>,
713    /// The human-readable explanation reused verbatim by the move warning and
714    /// by detail rendering.
715    pub reason: String,
716}
717
718/// How many nodes a single chain traversal will visit before setting
719/// [`DependencyChain::truncated`]. A defensive display guard, not a graph bound.
720///
721/// Public because a surface that reports truncation has to name the bound it
722/// hit, and a literal copy of this number in a printer is a number that drifts
723/// the moment this one changes (WP03's `tix chain` truncation note).
724pub const MAX_CHAIN_NODES: usize = 1_000;
725
726/// The in-memory relationship graph: one adjacency build, every derived read.
727///
728/// The struct is built once per read from an in-memory issue set — no
729/// filesystem access — and answers `relationships_for`, `blocks`,
730/// `blocked_by`, `duplicates_of`, `related_to`, `dependency_chain`,
731/// `blocked_state`, and `validate_snapshot` from the same indices.
732#[derive(Debug)]
733pub struct RelationshipGraph<'a> {
734    issues: HashMap<u32, IssueView<'a>>,
735    issues_by_number: Vec<u32>,
736    /// Stored relationships, sorted by `(kind, target, from)`; every accessor
737    /// slices this order so output is byte-comparable across surfaces.
738    edges: Vec<Relationship>,
739    by_id: HashMap<RelationshipId, usize>,
740    /// Indices into `edges` for every edge touching an issue, ascending.
741    by_issue: HashMap<u32, Vec<usize>>,
742    /// `blocks` adjacency only. `duplicates` and `related` never appear.
743    blocks_out: HashMap<u32, Vec<u32>>,
744    blocks_in: HashMap<u32, Vec<u32>>,
745    done_column: String,
746    /// How many times [`RelationshipGraph::validate_snapshot`] has run on this
747    /// graph.
748    ///
749    /// Instrumentation, and the **only** honest witness for NFR-001's "one pass".
750    /// A timing ratio detects a change in the scaling *exponent* — quadratic vs
751    /// linear — so it cannot see a second pass, which doubles both measurements and
752    /// leaves their ratio untouched. Adding a redundant `validate_cycles()` call to
753    /// `validate_snapshot` kept the entire suite green until this counter existed.
754    ///
755    /// An atomic so the graph stays `Sync`; the cost is one relaxed increment per
756    /// call, not per edge, so it does not affect the measurement it exists to
757    /// support.
758    validation_passes: std::sync::atomic::AtomicUsize,
759}
760
761impl Clone for RelationshipGraph<'_> {
762    /// A copy starts its own count. Cloning a graph is building a *new* one, so
763    /// inheriting the source's pass count would make the instrument lie.
764    fn clone(&self) -> Self {
765        Self {
766            issues: self.issues.clone(),
767            issues_by_number: self.issues_by_number.clone(),
768            edges: self.edges.clone(),
769            by_id: self.by_id.clone(),
770            by_issue: self.by_issue.clone(),
771            blocks_out: self.blocks_out.clone(),
772            blocks_in: self.blocks_in.clone(),
773            done_column: self.done_column.clone(),
774            validation_passes: std::sync::atomic::AtomicUsize::new(0),
775        }
776    }
777}
778
779impl<'a> RelationshipGraph<'a> {
780    /// How many validation passes this graph has run. See
781    /// [`RelationshipGraph::validation_passes`] for why this exists.
782    pub fn validation_passes(&self) -> usize {
783        self.validation_passes
784            .load(std::sync::atomic::Ordering::Relaxed)
785    }
786
787    /// Build the graph from an in-memory issue set.
788    ///
789    /// `done_column` is the board's configured completion column, used by the
790    /// resolution predicate in [`RelationshipGraph::blocked_state`]. Pass the
791    /// configured value (`BoardConfiguration::effective_done_column`), never a
792    /// hard-coded `"done"`.
793    ///
794    /// An empty `done_column` is accepted and means *no column marks
795    /// completion*, so nothing is treated as done. `Config` validation keeps
796    /// `columns` non-empty in practice, but a caller that passes `""` must not
797    /// silently get "an empty status counts as completed".
798    pub fn new(issues: &[IssueView<'a>], done_column: &str) -> Self {
799        let mut issues_by_number: Vec<u32> = issues.iter().map(|i| i.number).collect();
800        issues_by_number.sort_unstable();
801
802        let mut graph = Self {
803            issues: issues.iter().map(|i| (i.number, *i)).collect(),
804            issues_by_number,
805            edges: Vec::new(),
806            by_id: HashMap::new(),
807            by_issue: HashMap::new(),
808            blocks_out: HashMap::new(),
809            blocks_in: HashMap::new(),
810            done_column: done_column.to_string(),
811            validation_passes: std::sync::atomic::AtomicUsize::new(0),
812        };
813        graph.rebuild_edges();
814        graph
815    }
816
817    /// Re-derive the edge set and every index from the issue views.
818    ///
819    /// Stored edges are normalized on ingest, so a hand-edited file that
820    /// persisted the `blocked_by` spelling still contributes the correct
821    /// `blocks` adjacency instead of silently dropping out of blocked state.
822    ///
823    /// A relationship's own `from` is authoritative. It is deliberately *not*
824    /// overwritten with the owning issue's number: an edge authored as
825    /// `A blocked_by B` legitimately points at `A` from `B`'s side, so forcing
826    /// the owner would silently rewrite a normalized edge into a self-link.
827    fn rebuild_edges(&mut self) {
828        let mut edges: Vec<Relationship> = Vec::new();
829        for number in &self.issues_by_number {
830            let view = self.issues[number];
831            for rel in view.relationships {
832                let mut stored = rel.clone();
833                let canonical = stored.canonical();
834                stored.from = canonical.from;
835                stored.to = canonical.to;
836                stored.kind = canonical.kind;
837                // Refresh the display from the target's *current* title. The
838                // persisted string is a cache of a title that may since have
839                // been renamed, and every surface renders this field, so a
840                // stale value would be user-visible (review R1). A dangling
841                // edge keeps its persisted display, since there is no title to
842                // rebuild it from.
843                if let Some(target) = self.issues.get(&stored.to) {
844                    stored.refresh_display(target.title);
845                }
846                edges.push(stored);
847            }
848        }
849
850        // Deterministic order: (kind, target, from). Sorting here means every
851        // index built below inherits it, so no accessor has to sort again.
852        edges.sort_by(|a, b| (a.kind, a.to, a.from, &a.id).cmp(&(b.kind, b.to, b.from, &b.id)));
853
854        self.by_id = edges
855            .iter()
856            .enumerate()
857            .map(|(i, e)| (e.id.clone(), i))
858            .collect();
859        self.by_issue = HashMap::new();
860        self.blocks_out = HashMap::new();
861        self.blocks_in = HashMap::new();
862
863        for (i, edge) in edges.iter().enumerate() {
864            // Register each edge once per *distinct* endpoint. A self-link has
865            // one endpoint, so pushing both would report it twice and make
866            // `relationships_for(..).len()` disagree with `edge_count()`
867            // (review R2).
868            self.by_issue.entry(edge.from).or_default().push(i);
869            if edge.to != edge.from {
870                self.by_issue.entry(edge.to).or_default().push(i);
871            }
872            // A self-link is invalid data, reported by `validate_snapshot` as
873            // `self_link`. It stays visible through `relationships_for` so the
874            // user can find and fix it, but it is kept out of the graph
875            // adjacency: it is not a real edge, and admitting it would make an
876            // issue its own blocker (review R3).
877            if edge.kind == RelationKind::Blocks && edge.from != edge.to {
878                self.blocks_out.entry(edge.from).or_default().push(edge.to);
879                self.blocks_in.entry(edge.to).or_default().push(edge.from);
880            }
881        }
882
883        // Redundant rows are *kept* in `edges` so no stored relationship id
884        // silently stops resolving, but the adjacency is a set and must not
885        // repeat a neighbour. A hand-edited file can hold two spellings of one
886        // association (normalization makes them the same canonical triple);
887        // dropping a row here would destroy an id the user still holds, and
888        // `validate_edit` on it would report a bare `unknown_relation`
889        // indistinguishable from a typo (review round 3, finding A). The
890        // redundancy is collapsed on the read side instead — see
891        // `related_to` — where it costs the user nothing.
892        for adjacency in [&mut self.blocks_out, &mut self.blocks_in] {
893            for targets in adjacency.values_mut() {
894                targets.sort_unstable();
895                targets.dedup();
896            }
897        }
898
899        self.edges = edges;
900    }
901
902    /// The configured completion column used by the resolution predicate.
903    pub fn done_column(&self) -> &str {
904        &self.done_column
905    }
906
907    /// How many issues are in the graph.
908    pub fn issue_count(&self) -> usize {
909        self.issues_by_number.len()
910    }
911
912    /// How many stored edges are in the graph.
913    pub fn edge_count(&self) -> usize {
914        self.edges.len()
915    }
916
917    /// The issue numbers, ascending.
918    pub fn issue_numbers(&self) -> &[u32] {
919        &self.issues_by_number
920    }
921
922    /// Whether `number` resolves to an issue in the graph.
923    pub fn has_issue(&self, number: u32) -> bool {
924        self.issues.contains_key(&number)
925    }
926
927    /// The stored relationship with this stable id, if any.
928    pub fn find_relationship(&self, id: &str) -> Option<&Relationship> {
929        self.by_id.get(id).map(|i| &self.edges[*i])
930    }
931
932    /// The stored relationship for this canonical `(from, to, kind)`, if any.
933    ///
934    /// **This is the service's answer to "are these the same relationship?"**
935    /// A write path must use it rather than comparing endpoints itself, because
936    /// the comparison is domain logic and getting it wrong silently breaks
937    /// idempotency. `tix link 1 2 --type blocked_by` and `tix link 2 1
938    /// --type blocks` name one edge; a caller comparing the *authored*
939    /// endpoints would see two and append a second row.
940    ///
941    /// `from`, `to` and `kind` are the **authored** values. Canonicalization
942    /// happens here, so the caller cannot get the direction convention wrong:
943    ///
944    /// - `blocked_by` is normalized to the equivalent `blocks` edge with the
945    ///   endpoints swapped;
946    /// - `related` is order-normalized, so either orientation finds the row.
947    ///
948    /// When redundant rows exist for one canonical triple — a hand-edited file
949    /// can hold two spellings of one association — the lowest-id row is
950    /// returned, so the answer is stable across builds.
951    pub fn find_canonical(&self, from: u32, to: u32, kind: RelationKind) -> Option<&Relationship> {
952        let canonical = CanonicalEdge::new(from, to, kind);
953        // Scoped to the canonical source's edge list rather than the whole edge
954        // set: the matching row always has `from == canonical.from`, so this is
955        // a short list and stays cheap on the write path.
956        self.by_issue
957            .get(&canonical.from)?
958            .iter()
959            .map(|i| &self.edges[*i])
960            .find(|edge| edge.canonical() == canonical)
961    }
962
963    /// Every stored relationship, in deterministic order.
964    pub fn all_relationships(&self) -> &[Relationship] {
965        &self.edges
966    }
967
968    /// Every relationship touching `number` from either endpoint, in
969    /// deterministic `(kind, target)` order.
970    ///
971    /// An unknown issue yields an empty vector rather than an error; callers
972    /// that need an `unknown_issue` signal check
973    /// [`RelationshipGraph::has_issue`].
974    pub fn relationships_for(&self, number: u32) -> Vec<&Relationship> {
975        match self.by_issue.get(&number) {
976            Some(indices) => indices.iter().map(|i| &self.edges[*i]).collect(),
977            None => Vec::new(),
978        }
979    }
980
981    /// Relationships authored by `number` with the given kind.
982    pub fn relationships_of_kind(&self, number: u32, kind: RelationKind) -> Vec<&Relationship> {
983        self.relationships_for(number)
984            .into_iter()
985            .filter(|r| r.kind == kind)
986            .collect()
987    }
988
989    /// Issues `number` blocks, ascending.
990    pub fn blocks(&self, number: u32) -> &[u32] {
991        self.blocks_out
992            .get(&number)
993            .map(Vec::as_slice)
994            .unwrap_or(&[])
995    }
996
997    /// Issues blocking `number`, ascending.
998    ///
999    /// This is the raw inbound adjacency. It is **not** the open-blocker set —
1000    /// use [`RelationshipGraph::blocked_state`] for that, which applies the
1001    /// resolution predicate.
1002    pub fn blocked_by(&self, number: u32) -> &[u32] {
1003        self.blocks_in
1004            .get(&number)
1005            .map(Vec::as_slice)
1006            .unwrap_or(&[])
1007    }
1008
1009    /// Issues duplicated *by* `number`, ascending.
1010    ///
1011    /// `duplicates` is **directional** (`data-model.md` section 1), so this is
1012    /// deliberately one-directional: it reports the targets of the edges
1013    /// `number` declares and ignores edges that merely point *at* `number`. On
1014    /// a stored `2 duplicates 1` edge the answer is therefore empty — the claim
1015    /// is "2 duplicates 1", not "1 duplicates 2". Reading `r.to` over every
1016    /// edge touching the subject instead reports the subject itself (P4).
1017    pub fn duplicates_of(&self, number: u32) -> Vec<u32> {
1018        let mut targets: Vec<u32> = self
1019            .relationships_of_kind(number, RelationKind::Duplicates)
1020            .into_iter()
1021            .filter(|edge| edge.from == number)
1022            .map(|edge| edge.to)
1023            .collect();
1024        targets.sort_unstable();
1025        targets
1026    }
1027
1028    /// Issues associated with `number` by a `related` edge, ascending.
1029    ///
1030    /// `related` is the one **non-directional** kind, so both endpoints are
1031    /// equivalent. `relationships_for` returns edges from both sides, so the
1032    /// other side of each edge is projected explicitly — reading `r.to`
1033    /// directly reports the subject itself for an edge authored from the far
1034    /// side (P3).
1035    ///
1036    /// Redundant rows collapse *here* rather than in the stored edge set: two
1037    /// spellings of one association are one relationship to the user, and
1038    /// discarding a row at build time would silently invalidate an id the user
1039    /// still holds (round 3, finding A).
1040    pub fn related_to(&self, number: u32) -> Vec<u32> {
1041        let mut others: Vec<u32> = self
1042            .relationships_of_kind(number, RelationKind::Related)
1043            .into_iter()
1044            .map(|edge| {
1045                if edge.from == number {
1046                    edge.to
1047                } else {
1048                    edge.from
1049                }
1050            })
1051            .collect();
1052        others.sort_unstable();
1053        others.dedup();
1054        others
1055    }
1056
1057    // -----------------------------------------------------------------------
1058    // T005 — derived reads
1059    // -----------------------------------------------------------------------
1060
1061    /// The open blockers of `number`, per the resolution predicate.
1062    ///
1063    /// An inbound `blocks` edge counts **iff** the blocker is a *different*
1064    /// existing issue and its status is not the configured `done_column`. A
1065    /// dangling edge is excluded here (validation reports it as
1066    /// `missing_target`) so invalid data cannot permanently freeze an issue.
1067    pub fn open_blockers(&self, number: u32) -> Vec<ChainNode> {
1068        self.blocked_by(number)
1069            .iter()
1070            .filter(|blocker| self.is_open_blocker(number, **blocker))
1071            .map(|blocker| self.chain_node(*blocker))
1072            .collect()
1073    }
1074
1075    /// The single resolution predicate, and the whole of it.
1076    ///
1077    /// `contracts/relationship-service.md` §7 makes this function the sole
1078    /// owner of the rule, so every precondition is stated here rather than
1079    /// delegated to the adjacency build. Three things are not an open blocker:
1080    ///
1081    /// 1. a missing blocker — invalid data, diagnosed as `missing_target`;
1082    /// 2. a blocker in the board's configured `done_column` — it is finished;
1083    /// 3. the subject itself — an issue is never blocked by itself.
1084    ///
1085    /// On (3): `rebuild_edges` already keeps self-links out of the adjacency,
1086    /// so that branch is **unreachable today** and is covered by no passing
1087    /// test. It is retained deliberately rather than as a second live layer —
1088    /// a safety rule that states its own preconditions stays correct if the
1089    /// build step is ever changed, and the cost is one comparison. The test
1090    /// `the_blocked_adjacency_never_contains_the_subject` pins the invariant
1091    /// that makes it unreachable, so a regression there fails a test rather
1092    /// than silently leaning on this branch.
1093    fn is_open_blocker(&self, subject: u32, blocker: u32) -> bool {
1094        if blocker == subject {
1095            return false;
1096        }
1097        let Some(blocker) = self.issues.get(&blocker) else {
1098            return false;
1099        };
1100        // An unconfigured completion column must fail *closed*: with no
1101        // `done_column` no column means "finished", so nothing is completed
1102        // and every real blocker keeps blocking. Comparing the status against
1103        // an empty string instead would treat a blocker with an empty status
1104        // as done, which is the dangerous direction for a guard that gates a
1105        // confirmation prompt.
1106        self.done_column.is_empty() || blocker.status != self.done_column
1107    }
1108
1109    /// The derived blocked state for `number`.
1110    ///
1111    /// `reason` is the text the move warning and the detail view both display,
1112    /// so the two surfaces cannot drift.
1113    pub fn blocked_state(&self, number: u32) -> BlockedState {
1114        let blockers = self.open_blockers(number);
1115        let is_blocked = !blockers.is_empty();
1116        BlockedState {
1117            is_blocked,
1118            reason: if is_blocked {
1119                blocked_reason(&blockers)
1120            } else {
1121                String::new()
1122            },
1123            blockers,
1124        }
1125    }
1126
1127    /// The transitive dependency chain of `root`, walking **outbound** `blocks`
1128    /// edges: the issues this one blocks, and what they block in turn.
1129    ///
1130    /// Direction note: `data-model.md` §9 specifies the chain is derived "from
1131    /// outbound `blocks` edges", so this follows `blocks_out` and answers "what
1132    /// does this issue hold up?". The opposite question — "what blocks this
1133    /// issue?" — is what the move guard needs, and is answered by
1134    /// [`RelationshipGraph::open_blockers`] over the inbound adjacency.
1135    ///
1136    /// The traversal is iterative and marks visited nodes, so a hand-edited
1137    /// cyclic file terminates instead of hanging the TUI.
1138    pub fn dependency_chain(&self, root: u32) -> DependencyChain {
1139        let mut chain = DependencyChain {
1140            root,
1141            paths: Vec::new(),
1142            truncated: false,
1143        };
1144        if !self.has_issue(root) {
1145            return chain;
1146        }
1147
1148        let mut visited_nodes = 1usize;
1149        let mut path = vec![self.chain_node(root)];
1150        self.walk_downstream(root, &mut path, &mut chain.paths, &mut visited_nodes);
1151        chain.truncated = visited_nodes >= MAX_CHAIN_NODES;
1152        chain
1153    }
1154
1155    /// Iterative outbound walk, pushing one path per leaf of the closure.
1156    fn walk_downstream(
1157        &self,
1158        node: u32,
1159        path: &mut Vec<ChainNode>,
1160        paths: &mut Vec<Vec<ChainNode>>,
1161        visited_nodes: &mut usize,
1162    ) {
1163        let downstream = self.blocks(node);
1164        if downstream.is_empty() {
1165            return;
1166        }
1167        // Deduplicate: the same node reached through two paths must not fork
1168        // the walk twice.
1169        let mut unique: Vec<u32> = Vec::new();
1170        for target in downstream {
1171            if !unique.contains(target) {
1172                unique.push(*target);
1173            }
1174        }
1175        for target in unique {
1176            if *visited_nodes >= MAX_CHAIN_NODES {
1177                return;
1178            }
1179            *visited_nodes += 1;
1180            if path.iter().any(|n| n.number == target) {
1181                // Defensive only: write-time cycle rejection makes this
1182                // unreachable for a well-formed graph.
1183                continue;
1184            }
1185            path.push(self.chain_node(target));
1186            if self.blocks(target).is_empty() {
1187                paths.push(path.clone());
1188            } else {
1189                self.walk_downstream(target, path, paths, visited_nodes);
1190                path.pop();
1191            }
1192        }
1193    }
1194
1195    /// A chain node for `number`, falling back to an empty title for an edge
1196    /// endpoint that no longer resolves.
1197    fn chain_node(&self, number: u32) -> ChainNode {
1198        match self.issues.get(&number) {
1199            Some(issue) => ChainNode::new(number, issue.title),
1200            None => ChainNode::new(number, String::new()),
1201        }
1202    }
1203
1204    // -----------------------------------------------------------------------
1205    // T004 — validation
1206    // -----------------------------------------------------------------------
1207
1208    /// Sweep every stored edge and every issue, reporting every problem.
1209    ///
1210    /// A single `O(V + E)` pass, **never** an `Err`: `tix lint` consumes the
1211    /// returned value. Cycle detection reuses the `blocks` adjacency built at
1212    /// construction, so it is not a second scan.
1213    pub fn validate_snapshot(&self) -> RelationshipValidationResult {
1214        let mut result = RelationshipValidationResult::clean();
1215
1216        for number in &self.issues_by_number {
1217            let view = self.issues[number];
1218            // The wording names the **command** and the **flag**, not just the
1219            // kind vocabulary.
1220            //
1221            // An earlier version said "record the relationships as typed edges
1222            // (`blocks`, `blocked_by`, `duplicates`, or `related`)" — which reads
1223            // as though those four names were frontmatter *keys*. They are
1224            // `--type` values; the real field is `relationships:`, holding objects.
1225            // A user who obeyed that message literally would write `blocks: [2]`
1226            // at the top level, and then hit the worse bug underneath: the model
1227            // carries no unknown keys, so `tix lint` would certify the file and the
1228            // next unrelated `tix edit` would delete the key without a word.
1229            //
1230            // So the advice is the command that actually does it, and the
1231            // vocabulary is explicitly the argument's value. The README's
1232            // conversion section already said this; the diagnostic and the docs
1233            // disagreed, and the diagnostic is the one a user in the middle of a
1234            // problem will read.
1235            if view.has_retired_links_key {
1236                result.errors.push(Diagnostic::about(
1237                    *number,
1238                    DiagnosticKind::RetiredLinksKey,
1239                    "links",
1240                    format!(
1241                        "#{number} still carries the retired `links:` key, which the typed \
1242                         model no longer reads \u{2014} remove the key, then recreate each \
1243                         relationship with `tix link <from> <to> --type <kind>` (or the \
1244                         `link_issue` tool), where `<kind>` is one of `blocks`, \
1245                         `blocked_by`, `duplicates`, or `related`"
1246                    ),
1247                ));
1248            }
1249        }
1250
1251        // Per-edge rules. Each edge is examined once so an implied second
1252        // report for the same problem cannot appear.
1253        let mut pair_kind: HashMap<(u32, u32), RelationKind> = HashMap::new();
1254        for edge in &self.edges {
1255            if edge.from == edge.to {
1256                result.errors.push(Diagnostic::self_link(edge.from));
1257                continue;
1258            }
1259            if !self.has_issue(edge.to) {
1260                result
1261                    .errors
1262                    .push(Diagnostic::missing_target(edge.from, edge.to));
1263                continue;
1264            }
1265            // One authoritative kind per ordered pair: two different kinds for
1266            // the same pair is a conflict, an identical repeat is idempotent.
1267            let key = (edge.from, edge.to);
1268            match pair_kind.get(&key) {
1269                Some(existing) if *existing != edge.kind => {
1270                    // Attributed to `from`: that is the issue whose relationship
1271                    // set carries the conflict, and therefore the file lint names.
1272                    result.errors.push(Diagnostic::about(
1273                        edge.from,
1274                        DiagnosticKind::ConflictingEdge,
1275                        "kind",
1276                        format!(
1277                            "#{from} → #{to} is already `{existing}`; refusing to store it as `{}` as well",
1278                            edge.kind.as_str(),
1279                            from = edge.from,
1280                            to = edge.to
1281                        ),
1282                    ));
1283                }
1284                Some(_) => {}
1285                None => {
1286                    pair_kind.insert(key, edge.kind);
1287                }
1288            }
1289        }
1290
1291        result.merge(self.validate_cycles());
1292        result.sort_diagnostics();
1293        result
1294    }
1295
1296    /// Validate a proposed relationship creation.
1297    ///
1298    /// Runs every per-edge rule plus the cycle check, so the caller gets one
1299    /// blocking verdict before touching any file (SC-005).
1300    pub fn validate_create(
1301        &self,
1302        from: u32,
1303        to: u32,
1304        kind: RelationKind,
1305    ) -> RelationshipValidationResult {
1306        self.validate_authored(from, to, kind, None, Duplicate::Allowed)
1307    }
1308
1309    /// [`RelationshipGraph::validate_create`], with the ability to ignore one
1310    /// already-stored relationship and to say whether landing on an edge that
1311    /// already exists is an error.
1312    ///
1313    /// `exclude` is the id of the relationship being edited. An edge never
1314    /// conflicts with itself, so the edited record must not be counted while
1315    /// looking for a different kind on the same pair — but every *other*
1316    /// pre-existing edge on that pair still must. Filtering the finished result
1317    /// instead of excluding by id would also swallow a conflict that was
1318    /// already true of the stored data (review R4).
1319    fn validate_authored(
1320        &self,
1321        from: u32,
1322        to: u32,
1323        kind: RelationKind,
1324        exclude: Option<&str>,
1325        duplicate: Duplicate,
1326    ) -> RelationshipValidationResult {
1327        let mut result = RelationshipValidationResult::clean();
1328        let canonical = CanonicalEdge::new(from, to, kind);
1329
1330        if !self.has_issue(from) {
1331            result.errors.push(Diagnostic::missing_target(to, from));
1332        }
1333        if canonical.is_self_link() {
1334            result.errors.push(Diagnostic::self_link(from));
1335        } else if !self.has_issue(to) {
1336            result.errors.push(Diagnostic::missing_target(from, to));
1337        }
1338
1339        if let Some(existing) = self.relationships_for(from).into_iter().find(|r| {
1340            let c = r.canonical();
1341            c.from == canonical.from
1342                && c.to == canonical.to
1343                && c.kind != canonical.kind
1344                && exclude != Some(r.id.as_str())
1345        }) {
1346            result.errors.push(Diagnostic::new(
1347                DiagnosticKind::ConflictingEdge,
1348                "kind",
1349                format!(
1350                    "#{} → #{} is already `{}`; refusing to store it as `{}` as well",
1351                    canonical.from,
1352                    canonical.to,
1353                    existing.kind.as_str(),
1354                    canonical.kind.as_str()
1355                ),
1356            ));
1357        }
1358
1359        if let Some(cycle) = self.cycle_if_added(canonical) {
1360            result.errors.push(cycle);
1361        }
1362
1363        // Landing on a triple another relationship already occupies would give
1364        // two stored edges for one relationship, breaking the
1365        // `(from, to, kind)` uniqueness invariant (data-model §2). Only an edit
1366        // treats this as an error: a *create* that re-asserts an edge that
1367        // already exists is the idempotent no-op §2 requires, whereas an edit
1368        // would silently collapse two distinct relationship ids into one
1369        // (review P2).
1370        if duplicate == Duplicate::Rejected {
1371            if let Some(clash) = self
1372                .relationships_for(from)
1373                .into_iter()
1374                .find(|r| r.canonical() == canonical && exclude != Some(r.id.as_str()))
1375            {
1376                result.errors.push(Diagnostic::new(
1377                    DiagnosticKind::ConflictingEdge,
1378                    "kind",
1379                    format!(
1380                        "#{from} already has a `{}` relationship to #{} (`{}`); this edit would store a second one — drop the existing edge or pick a different kind",
1381                        canonical.kind.as_str(),
1382                        canonical.to,
1383                        clash.id
1384                    ),
1385                ));
1386            }
1387        }
1388
1389        result.sort_diagnostics();
1390        result
1391    }
1392
1393    /// Validate a proposed edit of an existing relationship.
1394    ///
1395    /// An edit naming its own current target and kind is the identity, not a
1396    /// duplicate — and nothing else is forgiven. The edit must still agree with
1397    /// the graph as it already stands, so `validate_edit` never returns a
1398    /// cleaner verdict than `validate_snapshot` for the same stored data.
1399    pub fn validate_edit(
1400        &self,
1401        id: &str,
1402        new_to: Option<u32>,
1403        new_kind: Option<RelationKind>,
1404    ) -> RelationshipValidationResult {
1405        let Some(existing) = self.find_relationship(id) else {
1406            let mut result = RelationshipValidationResult::clean();
1407            result.errors.push(Diagnostic::unknown_relation(id));
1408            return result;
1409        };
1410
1411        let from = existing.from;
1412        let to = new_to.unwrap_or(existing.to);
1413        let kind = new_kind.unwrap_or(existing.kind);
1414        self.validate_authored(from, to, kind, Some(id), Duplicate::Rejected)
1415    }
1416
1417    /// Detect every cycle in the `blocks` adjacency.
1418    ///
1419    /// Kahn's algorithm peels the acyclic part in `O(V + E)`; the residue is
1420    /// the set of nodes on, or downstream of, a cycle. One iterative
1421    /// three-colour DFS over that residue then names each distinct cycle, so
1422    /// the whole sweep stays `O(V + E)` rather than re-walking per node
1423    /// (NFR-001).
1424    fn validate_cycles(&self) -> RelationshipValidationResult {
1425        let mut result = RelationshipValidationResult::clean();
1426        for path in self.cycles() {
1427            // A cycle names every issue on it, so there is no single subject. The
1428            // **first** node stands in, because lint needs one file to hang the
1429            // finding on and the first node is the one whose relationship set a
1430            // reader has to edit to break the cycle. The message still names the
1431            // whole path, so the finding is not misleading about its scope.
1432            result.errors.push(Diagnostic::about(
1433                path[0],
1434                DiagnosticKind::Cycle,
1435                "to",
1436                format!("cycle: {}", render_cycle(&path)),
1437            ));
1438        }
1439        result
1440    }
1441
1442    /// Every distinct cycle in the `blocks` adjacency, each reported once.
1443    ///
1444    /// Iterative throughout: a 10,000-node chain must not overflow the stack.
1445    fn cycles(&self) -> Vec<Vec<u32>> {
1446        // Counted **here**, at the traversal, rather than at
1447        // `validate_snapshot`'s entry. A count at the entry witnesses how many
1448        // times the operation was *called*; this witnesses how many times the
1449        // blocks adjacency was actually *walked*, which is what "one pass" means.
1450        // A redundant `let _ = self.validate_cycles();` inside `validate_snapshot`
1451        // leaves an entry count at 1 and doubles this one — and that redundant call
1452        // kept the entire suite green until this counter existed.
1453        self.validation_passes
1454            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1455        let residue = self.cyclic_residue();
1456        if residue.is_empty() {
1457            return Vec::new();
1458        }
1459
1460        const WHITE: u8 = 0;
1461        const GREY: u8 = 1;
1462        const BLACK: u8 = 2;
1463
1464        let mut color: HashMap<u32, u8> = HashMap::new();
1465        let mut cycles: Vec<Vec<u32>> = Vec::new();
1466        let mut reported: HashSet<Vec<u32>> = HashSet::new();
1467        let mut path: Vec<u32> = Vec::new();
1468        let mut position: HashMap<u32, usize> = HashMap::new();
1469        let mut work: Vec<(u32, usize)> = Vec::new();
1470
1471        for start in &self.issues_by_number {
1472            let start = *start;
1473            if !residue.contains(&start) || color.get(&start) == Some(&BLACK) {
1474                continue;
1475            }
1476            color.insert(start, GREY);
1477            position.insert(start, path.len());
1478            path.push(start);
1479            work.push((start, 0));
1480
1481            while let Some(top) = work.last_mut() {
1482                let (node, cursor) = (top.0, top.1);
1483                let downstream = self.blocks(node);
1484                if cursor < downstream.len() {
1485                    top.1 += 1;
1486                    let next = downstream[cursor];
1487                    if !residue.contains(&next) {
1488                        continue;
1489                    }
1490                    match color.get(&next).copied().unwrap_or(WHITE) {
1491                        GREY => {
1492                            // Back edge: the cycle is the grey span on the path.
1493                            if let Some(start_at) = position.get(&next) {
1494                                let cycle = path[*start_at..].to_vec();
1495                                if reported.insert(cycle_key(&cycle)) {
1496                                    cycles.push(cycle);
1497                                }
1498                            }
1499                        }
1500                        WHITE => {
1501                            color.insert(next, GREY);
1502                            position.insert(next, path.len());
1503                            path.push(next);
1504                            work.push((next, 0));
1505                        }
1506                        _ => {}
1507                    }
1508                } else {
1509                    color.insert(node, BLACK);
1510                    position.remove(&node);
1511                    path.pop();
1512                    work.pop();
1513                }
1514            }
1515        }
1516        cycles
1517    }
1518
1519    /// The nodes Kahn's algorithm cannot free: those on, or downstream of, a
1520    /// directed cycle. Empty means the graph is acyclic.
1521    fn cyclic_residue(&self) -> HashSet<u32> {
1522        let mut indegree: HashMap<u32, usize> =
1523            self.issues_by_number.iter().map(|n| (*n, 0usize)).collect();
1524        for edge in &self.edges {
1525            if edge.kind != RelationKind::Blocks || edge.from == edge.to {
1526                continue;
1527            }
1528            if !self.has_issue(edge.from) || !self.has_issue(edge.to) {
1529                continue;
1530            }
1531            *indegree.entry(edge.to).or_default() += 1;
1532        }
1533
1534        let mut queue: VecDeque<u32> = indegree
1535            .iter()
1536            .filter(|(_, degree)| **degree == 0)
1537            .map(|(node, _)| *node)
1538            .collect();
1539        while let Some(node) = queue.pop_front() {
1540            // Self-links never reach the adjacency (see `rebuild_edges`), and
1541            // the degree is only decremented while positive, so a node is
1542            // enqueued exactly once and the peel terminates.
1543            for next in self.blocks(node) {
1544                if let Some(degree) = indegree.get_mut(next) {
1545                    if *degree > 0 {
1546                        *degree -= 1;
1547                        if *degree == 0 {
1548                            queue.push_back(*next);
1549                        }
1550                    }
1551                }
1552            }
1553        }
1554
1555        indegree
1556            .into_iter()
1557            .filter(|(_, degree)| *degree > 0)
1558            .map(|(node, _)| node)
1559            .collect()
1560    }
1561
1562    /// The `cycle` diagnostic an added edge would produce, if any.
1563    ///
1564    /// `from -> to` closes a cycle exactly when `to` already reaches `from`.
1565    /// The walk is iterative, so a 10,000-node chain is safe.
1566    fn cycle_if_added(&self, edge: CanonicalEdge) -> Option<Diagnostic> {
1567        if edge.is_self_link() {
1568            return Some(Diagnostic::self_link(edge.from));
1569        }
1570        // Only `blocks` can close a cycle. The reachability search below walks the
1571        // `blocks` adjacency, so running it for `related` or `duplicates` asks a
1572        // question about an edge that is not in that graph at all -- and answers
1573        // it wrongly, because the search finds a `blocks` path between the two
1574        // endpoints and reports a cycle for an edge that closes none.
1575        //
1576        // `data-model.md` §1 gives `duplicates` "no blocked-state effect", and
1577        // `contracts/relationship-service.md` §3.3 scopes the rule to "a **new
1578        // edge** that would close a directed cycle". Neither `related` nor
1579        // `duplicates` participates in the dependency graph, so neither can close
1580        // one.
1581        //
1582        // The cost of getting this wrong is not only a spurious rejection: with
1583        // `2 blocks 0` stored, re-kinding that edge to `related` is *refused*,
1584        // and the only route to a `related` edge between those two issues became
1585        // the destructive path in `Store::edit_relation`.
1586        if edge.kind != RelationKind::Blocks {
1587            return None;
1588        }
1589        if !self.has_issue(edge.to) {
1590            return None;
1591        }
1592        let path = self.shortest_path(edge.to, edge.from)?;
1593        let mut full = Vec::with_capacity(path.len() + 1);
1594        full.push(edge.from);
1595        full.extend(path);
1596        Some(Diagnostic::new(
1597            DiagnosticKind::Cycle,
1598            "to",
1599            format!("cycle: {}", render_cycle(&full)),
1600        ))
1601    }
1602
1603    /// An iterative breadth-first search from `start` to `goal`, returning the
1604    /// node sequence inclusive of both ends.
1605    fn shortest_path(&self, start: u32, goal: u32) -> Option<Vec<u32>> {
1606        if start == goal {
1607            return Some(vec![start]);
1608        }
1609        let mut previous: HashMap<u32, u32> = HashMap::new();
1610        let mut seen: HashSet<u32> = HashSet::new();
1611        let mut queue: std::collections::VecDeque<u32> = std::collections::VecDeque::new();
1612        seen.insert(start);
1613        queue.push_back(start);
1614        while let Some(node) = queue.pop_front() {
1615            for next in self.blocks(node) {
1616                if !seen.insert(*next) {
1617                    continue;
1618                }
1619                previous.insert(*next, node);
1620                if *next == goal {
1621                    let mut path = vec![goal];
1622                    let mut cursor = goal;
1623                    while let Some(step) = previous.get(&cursor) {
1624                        path.push(*step);
1625                        cursor = *step;
1626                    }
1627                    path.reverse();
1628                    return Some(path);
1629                }
1630                queue.push_back(*next);
1631            }
1632        }
1633        None
1634    }
1635}
1636
1637/// The move-warning / detail-view text for a non-empty blocker set.
1638///
1639/// Derived from [`BlockedState::blockers`] so every surface shows the same
1640/// string (parity contract §3).
1641pub fn blocked_reason(blockers: &[ChainNode]) -> String {
1642    if blockers.len() == 1 {
1643        let node = &blockers[0];
1644        return format!("Blocked by #{} {}", node.number, node.title)
1645            .trim_end()
1646            .to_string();
1647    }
1648    let list = blockers
1649        .iter()
1650        .map(|n| format!("#{} {}", n.number, n.title).trim_end().to_string())
1651        .collect::<Vec<_>>()
1652        .join(", ");
1653    format!("Blocked by {} issues: {list}", blockers.len())
1654}
1655
1656fn render_cycle(path: &[u32]) -> String {
1657    let rendered = path
1658        .iter()
1659        .map(u32::to_string)
1660        .collect::<Vec<_>>()
1661        .join(" -> ");
1662    format!("{rendered} -> #{}", path[0])
1663}
1664
1665fn cycle_key(path: &[u32]) -> Vec<u32> {
1666    // Canonical rotation so the same cycle is reported once regardless of
1667    // which node the walk happened to start from.
1668    let Some(min_position) = path
1669        .iter()
1670        .enumerate()
1671        .min_by_key(|(_, n)| **n)
1672        .map(|(i, _)| i)
1673    else {
1674        return path.to_vec();
1675    };
1676    let mut rotated = path[min_position..].to_vec();
1677    rotated.extend_from_slice(&path[..min_position]);
1678    rotated
1679}
1680
1681// ---------------------------------------------------------------------------
1682// Stable identifier generation (T001)
1683// ---------------------------------------------------------------------------
1684
1685/// Generate a stable relationship identifier.
1686///
1687/// The format is `rel_` followed by 26 Crockford base32 characters: a 48-bit
1688/// millisecond timestamp followed by 80 pseudo-random bits. It is ULID-shaped
1689/// so ids sort by creation time and stay stable across retarget, rename, and
1690/// export/import.
1691///
1692/// Randomness comes from the OS-seeded hasher state plus a process-local
1693/// counter, so the crate keeps **zero new dependencies** (C-001).
1694pub fn generate_relationship_id() -> RelationshipId {
1695    use std::hash::{BuildHasher, Hasher};
1696    use std::sync::atomic::{AtomicU64, Ordering};
1697    use std::time::{SystemTime, UNIX_EPOCH};
1698
1699    static COUNTER: AtomicU64 = AtomicU64::new(0);
1700
1701    let millis = SystemTime::now()
1702        .duration_since(UNIX_EPOCH)
1703        .map(|d| d.as_millis())
1704        .unwrap_or(0);
1705    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
1706    let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
1707    hasher.write_u128(millis);
1708    hasher.write_u64(counter);
1709    let entropy = hasher.finish();
1710    // 80 bits: 64 from the hasher, 16 more from a second pass over the counter.
1711    let mut second = std::collections::hash_map::RandomState::new().build_hasher();
1712    second.write_u64(entropy);
1713    second.write_u64(counter.wrapping_mul(0x9E37_79B9_7F4A_7C15));
1714    let high = second.finish() & 0xFFFF;
1715
1716    let value = (millis << 80u32) | (u128::from(entropy) << 16) | u128::from(high);
1717    // The Crockford encoder is shared with the issue-id generator (`crate::id`),
1718    // so the crate has one base32 implementation rather than two.
1719    format!("rel_{}", crate::id::crockford(value, 26))
1720}
1721
1722// ---------------------------------------------------------------------------
1723// Tests
1724// ---------------------------------------------------------------------------
1725
1726#[cfg(test)]
1727mod tests {
1728    use super::*;
1729
1730    // -- test fixtures -----------------------------------------------------
1731
1732    /// An owned issue, so tests can build borrowed [`IssueView`]s from a
1733    /// stable backing allocation.
1734    #[derive(Clone)]
1735    struct Owned {
1736        number: u32,
1737        title: String,
1738        status: String,
1739        relationships: Vec<Relationship>,
1740        retired: bool,
1741    }
1742
1743    impl Owned {
1744        fn new(number: u32, title: &str, status: &str) -> Self {
1745            Self {
1746                number,
1747                title: title.to_string(),
1748                status: status.to_string(),
1749                relationships: Vec::new(),
1750                retired: false,
1751            }
1752        }
1753
1754        /// Consuming builder: this issue blocks `to`.
1755        fn blocking(mut self, to: u32, title: &str) -> Self {
1756            self.relationships.push(Relationship::new(
1757                self.number,
1758                to,
1759                RelationKind::Blocks,
1760                title,
1761            ));
1762            self
1763        }
1764
1765        /// In-place builder, for loops that build many edges on one issue.
1766        fn push_block(&mut self, to: u32, title: &str) {
1767            self.relationships.push(Relationship::new(
1768                self.number,
1769                to,
1770                RelationKind::Blocks,
1771                title,
1772            ));
1773        }
1774
1775        /// This issue is `related` to `to`.
1776        fn with_related(self, to: u32, title: &str) -> Self {
1777            let mut this = self;
1778            this.relationships.push(Relationship::new(
1779                this.number,
1780                to,
1781                RelationKind::Related,
1782                title,
1783            ));
1784            this
1785        }
1786
1787        fn with_retired_links_key(mut self) -> Self {
1788            self.retired = true;
1789            self
1790        }
1791
1792        fn view(&self) -> IssueView<'_> {
1793            IssueView::new(self.number, &self.title, &self.status, &self.relationships)
1794                .with_retired_links_key(self.retired)
1795        }
1796    }
1797
1798    fn graph_of<'x>(owned: &'x [Owned], done_column: &str) -> RelationshipGraph<'x> {
1799        let views: Vec<IssueView<'x>> = owned.iter().map(Owned::view).collect();
1800        RelationshipGraph::new(&views, done_column)
1801    }
1802
1803    // -- T001: vocabulary --------------------------------------------------
1804
1805    #[test]
1806    fn relation_kind_has_exactly_four_variants_with_canonical_spellings() {
1807        let spellings: Vec<&str> = RelationKind::ALL.iter().map(|k| k.as_str()).collect();
1808        assert_eq!(spellings, ["blocks", "blocked_by", "duplicates", "related"]);
1809    }
1810
1811    #[test]
1812    fn is_directional_is_false_only_for_related() {
1813        for kind in RelationKind::ALL {
1814            assert_eq!(
1815                kind.is_directional(),
1816                kind != RelationKind::Related,
1817                "{kind} directionality"
1818            );
1819        }
1820    }
1821
1822    #[test]
1823    fn inverse_truth_table_matches_the_contract() {
1824        assert_eq!(
1825            RelationKind::Blocks.inverse(),
1826            Some(RelationKind::BlockedBy)
1827        );
1828        assert_eq!(
1829            RelationKind::BlockedBy.inverse(),
1830            Some(RelationKind::Blocks)
1831        );
1832        assert_eq!(RelationKind::Duplicates.inverse(), None);
1833        assert_eq!(RelationKind::Related.inverse(), None);
1834    }
1835
1836    #[test]
1837    fn from_str_parses_every_canonical_spelling() {
1838        for kind in RelationKind::ALL {
1839            assert_eq!(kind.as_str().parse::<RelationKind>().unwrap(), kind);
1840        }
1841    }
1842
1843    #[test]
1844    fn unknown_kind_is_rejected_and_never_coerced_to_related() {
1845        let err = "depends_on".parse::<RelationKind>().unwrap_err();
1846        assert_eq!(err.kind, DiagnosticKind::UnknownKind);
1847        assert!(err.message.contains("depends_on"));
1848        assert!(err.message.contains("blocks"));
1849    }
1850
1851    #[test]
1852    fn relationship_serde_round_trip_is_stable() {
1853        let rel = Relationship::new(42, 51, RelationKind::Blocks, "Refactor store");
1854        let yaml = serde_yaml::to_string(&rel).unwrap();
1855        let parsed: Relationship = serde_yaml::from_str(&yaml).unwrap();
1856        assert_eq!(rel, parsed);
1857        assert_eq!(serde_yaml::to_string(&parsed).unwrap(), yaml);
1858    }
1859
1860    #[test]
1861    fn relationship_parses_with_display_omitted() {
1862        let yaml = "id: rel_1\nfrom: 1\nto: 2\nkind: blocks\n";
1863        let rel: Relationship = serde_yaml::from_str(yaml).unwrap();
1864        assert_eq!(rel.display, "");
1865        // An empty display is omitted rather than written as `display: ""`.
1866        assert!(!serde_yaml::to_string(&rel).unwrap().contains("display"));
1867    }
1868
1869    #[test]
1870    fn display_name_combines_kind_number_and_title() {
1871        assert_eq!(
1872            display_name(RelationKind::Blocks, 42, "Fix flaky test"),
1873            "blocks #42 Fix flaky test"
1874        );
1875        assert_eq!(
1876            display_name(RelationKind::Related, 7, "   "),
1877            "related #7",
1878            "a blank title must not leave a trailing space"
1879        );
1880    }
1881
1882    #[test]
1883    fn stable_ids_are_unique_across_a_batch_and_prefixed() {
1884        let mut seen = std::collections::HashSet::new();
1885        for _ in 0..2_000 {
1886            let id = generate_relationship_id();
1887            assert!(id.starts_with("rel_"), "unexpected id shape: {id}");
1888            assert_eq!(id.len(), "rel_".len() + 26, "unexpected id length: {id}");
1889            assert!(
1890                id["rel_".len()..]
1891                    .bytes()
1892                    .all(|b| crate::id::CROCKFORD.contains(&b)),
1893                "non-Crockford character in {id}"
1894            );
1895            assert!(seen.insert(id), "duplicate relationship id generated");
1896        }
1897    }
1898
1899    // -- T002: direction convention ----------------------------------------
1900
1901    #[test]
1902    fn both_spellings_of_a_blocking_pair_normalize_to_one_canonical_edge() {
1903        let forward = normalize(1, 2, RelationKind::Blocks);
1904        let inverse = normalize(1, 2, RelationKind::BlockedBy);
1905        assert_eq!(
1906            forward,
1907            CanonicalEdge {
1908                from: 1,
1909                to: 2,
1910                kind: RelationKind::Blocks
1911            }
1912        );
1913        assert_eq!(
1914            inverse,
1915            CanonicalEdge {
1916                from: 2,
1917                to: 1,
1918                kind: RelationKind::Blocks
1919            },
1920            "blocked_by must swap the endpoints into the canonical blocks form"
1921        );
1922        assert_eq!(
1923            forward.to, inverse.from,
1924            "the two spellings are the same edge"
1925        );
1926    }
1927
1928    #[test]
1929    fn a_persisted_blocking_edge_never_carries_the_blocked_by_kind() {
1930        let stored = Relationship::new(1, 2, RelationKind::BlockedBy, "T");
1931        assert_eq!(stored.kind, RelationKind::Blocks);
1932        assert_eq!((stored.from, stored.to), (2, 1));
1933    }
1934
1935    #[test]
1936    fn normalize_all_deduplicates_both_spellings_and_is_idempotent() {
1937        // "1 blocks 2" and "2 is blocked by 1" are one edge.
1938        let authored = [
1939            (1, 2, RelationKind::Blocks),
1940            (2, 1, RelationKind::BlockedBy),
1941            (3, 4, RelationKind::Related),
1942        ];
1943        let once = normalize_all(authored);
1944        assert_eq!(once.len(), 2, "the two spellings collapse to one edge");
1945        assert_eq!(
1946            once,
1947            normalize_all(authored),
1948            "normalization is deterministic"
1949        );
1950        assert_eq!(
1951            once,
1952            normalize_all(
1953                once.iter()
1954                    .map(|e| (e.from, e.to, e.kind))
1955                    .collect::<Vec<_>>()
1956            )
1957        );
1958    }
1959
1960    #[test]
1961    fn related_and_duplicates_never_swap_endpoints() {
1962        for kind in [RelationKind::Related, RelationKind::Duplicates] {
1963            let edge = normalize(1, 2, kind);
1964            assert_eq!((edge.from, edge.to, edge.kind), (1, 2, kind));
1965        }
1966    }
1967
1968    #[test]
1969    fn a_reciprocal_blocking_pair_is_rejected_as_a_cycle() {
1970        let a = Owned::new(1, "A", "todo").blocking(2, "B");
1971        let b = Owned::new(2, "B", "todo").blocking(1, "A");
1972        let owned = vec![a, b];
1973        let graph = graph_of(&owned, "done");
1974
1975        let result = graph.validate_snapshot();
1976        assert!(
1977            result.has(DiagnosticKind::Cycle),
1978            "expected a cycle diagnostic: {result:?}"
1979        );
1980        assert!(result.blocking(), "a reciprocal pair must be blocking");
1981    }
1982
1983    #[test]
1984    fn a_conflicting_kind_for_the_same_pair_is_reported() {
1985        let a = Owned::new(1, "A", "todo").blocking(2, "B");
1986        let mut a = a;
1987        a.relationships
1988            .push(Relationship::new(1, 2, RelationKind::Related, "B"));
1989        let owned = vec![a, Owned::new(2, "B", "todo")];
1990        let result = graph_of(&owned, "done").validate_snapshot();
1991        assert!(
1992            result.has(DiagnosticKind::ConflictingEdge),
1993            "expected conflicting_edge: {result:?}"
1994        );
1995    }
1996
1997    // -- T003: graph build and ordering ------------------------------------
1998
1999    #[test]
2000    fn edges_are_returned_from_both_endpoints() {
2001        let a = Owned::new(1, "A", "todo").blocking(2, "B");
2002        let owned = vec![a, Owned::new(2, "B", "todo")];
2003        let graph = graph_of(&owned, "done");
2004        assert_eq!(graph.relationships_for(1).len(), 1);
2005        assert_eq!(
2006            graph.relationships_for(2).len(),
2007            1,
2008            "the target sees the edge too"
2009        );
2010    }
2011
2012    #[test]
2013    fn ordering_is_stable_across_repeated_builds() {
2014        let a = Owned::new(1, "A", "todo")
2015            .blocking(4, "D")
2016            .blocking(2, "B")
2017            .blocking(3, "C");
2018        let owned = vec![
2019            a,
2020            Owned::new(2, "B", "todo"),
2021            Owned::new(3, "C", "todo"),
2022            Owned::new(4, "D", "todo"),
2023        ];
2024        let first: Vec<(RelationKind, u32)> = graph_of(&owned, "done")
2025            .relationships_for(1)
2026            .iter()
2027            .map(|r| (r.kind, r.to))
2028            .collect();
2029        let second: Vec<(RelationKind, u32)> = graph_of(&owned, "done")
2030            .relationships_for(1)
2031            .iter()
2032            .map(|r| (r.kind, r.to))
2033            .collect();
2034        assert_eq!(first, second, "repeated builds must be byte-comparable");
2035        assert_eq!(
2036            first,
2037            vec![
2038                (RelationKind::Blocks, 2),
2039                (RelationKind::Blocks, 3),
2040                (RelationKind::Blocks, 4)
2041            ],
2042            "edges are ordered by (kind, target)"
2043        );
2044    }
2045
2046    #[test]
2047    fn related_and_duplicates_never_enter_blocked_adjacency() {
2048        let mut a = Owned::new(1, "A", "todo");
2049        a.relationships
2050            .push(Relationship::new(1, 2, RelationKind::Related, "B"));
2051        a.relationships
2052            .push(Relationship::new(1, 3, RelationKind::Duplicates, "C"));
2053        let owned = vec![a, Owned::new(2, "B", "todo"), Owned::new(3, "C", "todo")];
2054        let graph = graph_of(&owned, "done");
2055        assert!(
2056            graph.blocks(1).is_empty(),
2057            "related/duplicates are not blockers"
2058        );
2059        assert_eq!(graph.blocked_by(2), [] as [u32; 0]);
2060        assert_eq!(graph.blocked_by(3), [] as [u32; 0]);
2061        assert_eq!(graph.duplicates_of(1), vec![3]);
2062        assert_eq!(graph.related_to(1), vec![2]);
2063    }
2064
2065    #[test]
2066    fn an_issue_with_no_edges_yields_empty_results_not_an_error() {
2067        let owned = vec![Owned::new(1, "A", "todo")];
2068        let graph = graph_of(&owned, "done");
2069        assert_eq!(
2070            graph.relationships_for(1),
2071            [] as [&crate::relationships::Relationship; 0]
2072        );
2073        assert_eq!(graph.blocks(1), [] as [u32; 0]);
2074        assert_eq!(graph.blocked_by(1), [] as [u32; 0]);
2075        assert_eq!(
2076            graph.open_blockers(1),
2077            [] as [crate::relationships::ChainNode; 0]
2078        );
2079        assert!(graph.dependencies_are_empty_for_unknown_issue());
2080    }
2081
2082    impl RelationshipGraph<'_> {
2083        /// Small readability helper for the empty-result test.
2084        fn dependencies_are_empty_for_unknown_issue(&self) -> bool {
2085            self.dependency_chain(9_999).paths.is_empty()
2086        }
2087    }
2088
2089    // -- T004: validation --------------------------------------------------
2090
2091    #[test]
2092    fn an_empty_snapshot_validates_clean() {
2093        let owned = vec![Owned::new(1, "A", "todo")];
2094        let result = graph_of(&owned, "done").validate_snapshot();
2095        assert!(result.is_clean(), "unexpected diagnostics: {result:?}");
2096        assert!(!result.blocking());
2097    }
2098
2099    #[test]
2100    fn self_link_create_is_rejected() {
2101        let owned = vec![Owned::new(1, "A", "todo")];
2102        let result = graph_of(&owned, "done").validate_create(1, 1, RelationKind::Blocks);
2103        assert!(result.has(DiagnosticKind::SelfLink), "{result:?}");
2104        assert!(result.blocking());
2105    }
2106
2107    #[test]
2108    fn missing_target_create_is_rejected() {
2109        let owned = vec![Owned::new(1, "A", "todo")];
2110        let result = graph_of(&owned, "done").validate_create(1, 99, RelationKind::Blocks);
2111        assert!(result.has(DiagnosticKind::MissingTarget), "{result:?}");
2112    }
2113
2114    #[test]
2115    fn validate_create_accepts_both_spellings_of_the_same_pair() {
2116        let owned = vec![Owned::new(1, "A", "todo"), Owned::new(2, "B", "todo")];
2117        let graph = graph_of(&owned, "done");
2118        assert!(graph.validate_create(1, 2, RelationKind::Blocks).is_clean());
2119        assert!(
2120            graph
2121                .validate_create(1, 2, RelationKind::BlockedBy)
2122                .is_clean(),
2123            "the inverse spelling is the same edge, not a conflict"
2124        );
2125    }
2126
2127    #[test]
2128    fn editing_an_unknown_relationship_id_is_reported() {
2129        let owned = vec![Owned::new(1, "A", "todo")];
2130        let result = graph_of(&owned, "done").validate_edit("rel_missing", None, None);
2131        assert!(result.has(DiagnosticKind::UnknownRelation), "{result:?}");
2132    }
2133
2134    #[test]
2135    fn a_retired_links_key_is_reported_and_never_read_as_typed_data() {
2136        let owned = vec![
2137            Owned::new(1, "A", "todo").with_retired_links_key(),
2138            Owned::new(2, "B", "todo"),
2139        ];
2140        let result = graph_of(&owned, "done").validate_snapshot();
2141        assert!(result.has(DiagnosticKind::RetiredLinksKey), "{result:?}");
2142        assert_eq!(
2143            result
2144                .errors
2145                .iter()
2146                .find(|d| d.kind == DiagnosticKind::RetiredLinksKey)
2147                .map(|d| d.field.as_str()),
2148            Some("links")
2149        );
2150    }
2151
2152    #[test]
2153    fn a_snapshot_with_three_distinct_problems_reports_exactly_three_diagnostics() {
2154        let mut self_linked = Owned::new(1, "A", "todo");
2155        self_linked
2156            .relationships
2157            .push(Relationship::new(3, 3, RelationKind::Blocks, "C"));
2158        let mut dangling = Owned::new(2, "B", "todo");
2159        dangling
2160            .relationships
2161            .push(Relationship::new(2, 404, RelationKind::Blocks, "gone"));
2162        let owned = vec![
2163            self_linked,
2164            dangling,
2165            Owned::new(3, "C", "todo").with_retired_links_key(),
2166        ];
2167        let result = graph_of(&owned, "done").validate_snapshot();
2168        assert_eq!(
2169            result.errors.len(),
2170            3,
2171            "expected no duplicate or implied reports: {result:?}"
2172        );
2173    }
2174
2175    // -- T005: cycles, chains, blocked state -------------------------------
2176
2177    #[test]
2178    fn a_three_chain_returns_the_ordered_path() {
2179        let a = Owned::new(1, "A", "todo").blocking(2, "B");
2180        let b = Owned::new(2, "B", "todo").blocking(3, "C");
2181        let owned = vec![a, b, Owned::new(3, "C", "todo")];
2182        let chain = graph_of(&owned, "done").dependency_chain(1);
2183        assert_eq!(chain.root, 1);
2184        assert_eq!(chain.paths.len(), 1);
2185        assert_eq!(
2186            chain.paths[0].iter().map(|n| n.number).collect::<Vec<_>>(),
2187            vec![1, 2, 3],
2188            "the chain walks upstream from the root toward its blockers"
2189        );
2190        assert!(!chain.truncated);
2191    }
2192
2193    #[test]
2194    fn a_cycle_closing_edge_is_rejected() {
2195        let a = Owned::new(1, "A", "todo").blocking(2, "B");
2196        let b = Owned::new(2, "B", "todo").blocking(3, "C");
2197        let owned = vec![a, b, Owned::new(3, "C", "todo")];
2198        let result = graph_of(&owned, "done").validate_create(3, 1, RelationKind::Blocks);
2199        assert!(result.has(DiagnosticKind::Cycle), "{result:?}");
2200        assert!(result.blocking());
2201    }
2202
2203    #[test]
2204    fn blocked_state_is_true_only_for_an_open_inbound_blocks_edge() {
2205        let a = Owned::new(1, "A", "todo").blocking(2, "B");
2206        let owned = vec![a, Owned::new(2, "B", "todo")];
2207        let graph = graph_of(&owned, "done");
2208
2209        let state = graph.blocked_state(2);
2210        assert!(state.is_blocked);
2211        assert_eq!(
2212            state.blockers.iter().map(|n| n.number).collect::<Vec<_>>(),
2213            vec![1]
2214        );
2215        assert!(
2216            state.reason.contains("#1"),
2217            "the reason names the blocker: {}",
2218            state.reason
2219        );
2220
2221        assert!(
2222            !graph.blocked_state(1).is_blocked,
2223            "the blocker is not itself blocked"
2224        );
2225    }
2226
2227    #[test]
2228    fn moving_the_blocker_into_the_configured_done_column_clears_blocked_state() {
2229        let a = Owned::new(1, "A", "todo").blocking(2, "B");
2230        let owned = vec![a, Owned::new(2, "B", "todo")];
2231        // A board whose completion column is "shipped", not "done".
2232        let graph = graph_of(&owned, "shipped");
2233        assert!(
2234            graph.blocked_state(2).is_blocked,
2235            "A is not in the shipped column yet"
2236        );
2237
2238        let mut moved = owned.clone();
2239        moved[0].status = "shipped".to_string();
2240        let after = graph_of(&moved, "shipped");
2241        assert!(
2242            !after.blocked_state(2).is_blocked,
2243            "a blocker in the configured done_column stops blocking"
2244        );
2245    }
2246
2247    #[test]
2248    fn a_blocker_in_a_non_completion_column_keeps_blocking() {
2249        let a = Owned::new(1, "A", "in review").blocking(2, "B");
2250        let owned = vec![a, Owned::new(2, "B", "todo")];
2251        let graph = graph_of(&owned, "shipped");
2252        assert!(graph.blocked_state(2).is_blocked);
2253        assert_ne!(graph.blocked_state(2).reason, "");
2254    }
2255
2256    #[test]
2257    fn a_dangling_edge_is_diagnosed_but_does_not_freeze_the_issue() {
2258        // Edge 1 -> 404 where 404 does not exist: validation reports it, but the
2259        // blocked-state derivation must exclude it.
2260        let mut blocker = Owned::new(1, "A", "todo");
2261        blocker
2262            .relationships
2263            .push(Relationship::new(1, 404, RelationKind::Blocks, "gone"));
2264        let owned = vec![blocker, Owned::new(2, "B", "todo")];
2265        let graph = graph_of(&owned, "done");
2266
2267        let result = graph.validate_snapshot();
2268        assert!(
2269            result.has(DiagnosticKind::MissingTarget),
2270            "the dangling edge is still reported: {result:?}"
2271        );
2272        assert!(
2273            !graph.blocked_state(2).is_blocked,
2274            "invalid data must not permanently freeze an issue"
2275        );
2276    }
2277
2278    #[test]
2279    fn a_hand_edited_cyclic_file_terminates_instead_of_hanging() {
2280        // Write-time rejection makes this unreachable, but a hand-edited file
2281        // must not hang the TUI.
2282        let a = Owned::new(1, "A", "todo").blocking(2, "B");
2283        let b = Owned::new(2, "B", "todo").blocking(1, "A");
2284        let owned = vec![a, b];
2285        let graph = graph_of(&owned, "done");
2286
2287        assert!(graph.validate_snapshot().has(DiagnosticKind::Cycle));
2288        let chain = graph.dependency_chain(1);
2289        assert!(
2290            chain.paths.iter().all(|p| p.len() <= owned.len()),
2291            "the visited-set guard must bound every path"
2292        );
2293    }
2294
2295    // -- NFR-001: linear single pass ---------------------------------------
2296
2297    #[test]
2298    fn a_ten_thousand_edge_graph_validates_in_one_pass() {
2299        const ISSUES: u32 = 2_000;
2300        const PER_ISSUE: u32 = 6;
2301        // Forward-only edges (lower index blocks higher) keep the graph acyclic,
2302        // so a clean verdict is the correct expectation.
2303        let mut owned: Vec<Owned> = (0..ISSUES)
2304            .map(|n| Owned::new(n + 1, &format!("Issue {n}"), "todo"))
2305            .collect();
2306        for n in 0..ISSUES {
2307            for k in 1..=PER_ISSUE {
2308                let target = n + k;
2309                if target < ISSUES {
2310                    owned[n as usize].push_block(target + 1, "Target");
2311                }
2312            }
2313        }
2314        let graph = graph_of(&owned, "done");
2315        assert!(
2316            graph.edge_count() >= 10_000,
2317            "expected a 10,000-edge graph, built {}",
2318            graph.edge_count()
2319        );
2320
2321        // Measure structure (pass completed, correct result) rather than a
2322        // wall-clock bound so the suite does not go flaky on shared CI hardware.
2323        let result = graph.validate_snapshot();
2324        assert!(
2325            result.is_clean(),
2326            "a well-formed graph validates clean: {result:?}"
2327        );
2328        assert_eq!(graph.issue_count(), ISSUES as usize);
2329        assert!(graph.blocked_state(2).is_blocked);
2330    }
2331
2332    #[test]
2333    fn a_ten_thousand_node_chain_does_not_overflow_the_stack() {
2334        const DEPTH: u32 = 10_000;
2335        let mut owned: Vec<Owned> = (0..DEPTH)
2336            .map(|n| Owned::new(n + 1, &format!("Node {n}"), "todo"))
2337            .collect();
2338        for n in 0..DEPTH - 1 {
2339            owned[n as usize].push_block(n + 2, "Next");
2340        }
2341        let graph = graph_of(&owned, "done");
2342        // Iterative traversal: a recursive DFS would blow the stack here.
2343        assert!(graph.validate_snapshot().is_clean());
2344        let chain = graph.dependency_chain(1);
2345        assert!(chain.truncated, "the defensive node bound engages");
2346        assert!(
2347            graph
2348                .cycle_if_added(CanonicalEdge {
2349                    from: DEPTH,
2350                    to: 1,
2351                    kind: RelationKind::Blocks
2352                })
2353                .is_some()
2354        );
2355    }
2356
2357    // -- review round 4 regression -----------------------------------------
2358
2359    #[test]
2360    fn d1_inverse_spellings_find_the_same_row() {
2361        // Stored: 1 blocks 2.
2362        let owned = vec![
2363            Owned::new(1, "A", "todo").blocking(2, "B"),
2364            Owned::new(2, "B", "todo"),
2365        ];
2366        let graph = graph_of(&owned, "done");
2367        let forward = graph
2368            .find_canonical(1, 2, RelationKind::Blocks)
2369            .map(|r| r.id.clone());
2370        let inverse = graph
2371            .find_canonical(2, 1, RelationKind::BlockedBy)
2372            .map(|r| r.id.clone());
2373        assert!(forward.is_some(), "the stored edge must be found");
2374        assert_eq!(
2375            forward, inverse,
2376            "1 blocks 2 and 2 blocked_by 1 name one relationship"
2377        );
2378        assert_ne!(
2379            forward,
2380            graph
2381                .find_canonical(1, 2, RelationKind::BlockedBy)
2382                .map(|r| r.id.clone()),
2383            "1 blocked_by 2 is the inverse of the stored edge, so it must not match"
2384        );
2385    }
2386
2387    #[test]
2388    fn d1_related_is_found_from_either_orientation() {
2389        let mut two = Owned::new(2, "B", "todo");
2390        two.relationships
2391            .push(Relationship::new(2, 1, RelationKind::Related, "A"));
2392        let owned = vec![Owned::new(1, "A", "todo"), two];
2393        let graph = graph_of(&owned, "done");
2394        let a = graph
2395            .find_canonical(1, 2, RelationKind::Related)
2396            .map(|r| r.id.clone());
2397        let b = graph
2398            .find_canonical(2, 1, RelationKind::Related)
2399            .map(|r| r.id.clone());
2400        assert!(a.is_some(), "related is order-normalized, so it is found");
2401        assert_eq!(a, b);
2402    }
2403
2404    #[test]
2405    fn d1_a_self_link_or_unrelated_pair_does_not_match() {
2406        let owned = vec![
2407            Owned::new(1, "A", "todo").blocking(2, "B"),
2408            Owned::new(2, "B", "todo"),
2409            Owned::new(3, "C", "todo"),
2410        ];
2411        let graph = graph_of(&owned, "done");
2412        assert!(graph.find_canonical(1, 1, RelationKind::Blocks).is_none());
2413        assert!(graph.find_canonical(1, 3, RelationKind::Blocks).is_none());
2414        assert!(graph.find_canonical(3, 1, RelationKind::Blocks).is_none());
2415        assert!(graph.find_canonical(1, 2, RelationKind::Related).is_none());
2416        assert!(
2417            graph.find_canonical(1, 2, RelationKind::Blocks).is_some(),
2418            "the real edge still resolves"
2419        );
2420    }
2421
2422    #[test]
2423    fn d1_redundant_rows_resolve_to_the_same_row_every_time() {
2424        let one = Owned::new(1, "A", "todo").with_related(2, "B");
2425        let two = Owned::new(2, "B", "todo").with_related(1, "A");
2426        let owned = vec![one, two];
2427        let first = graph_of(&owned, "done")
2428            .find_canonical(1, 2, RelationKind::Related)
2429            .map(|r| r.id.clone());
2430        let second = graph_of(&owned, "done")
2431            .find_canonical(1, 2, RelationKind::Related)
2432            .map(|r| r.id.clone());
2433        assert!(first.is_some());
2434        assert_eq!(first, second, "the answer must be stable, not arbitrary");
2435    }
2436
2437    #[test]
2438    fn d1_an_unknown_pair_returns_none() {
2439        let owned = vec![Owned::new(1, "A", "todo")];
2440        let graph = graph_of(&owned, "done");
2441        assert!(graph.find_canonical(1, 404, RelationKind::Blocks).is_none());
2442        assert!(graph.find_canonical(404, 1, RelationKind::Blocks).is_none());
2443    }
2444
2445    // -- review round 2 regressions ---------------------------------------
2446
2447    #[test]
2448    fn p1_related_reads_as_one_association_across_orientations() {
2449        // `related` is the only non-directional kind, so both authorings are
2450        // the same association. Normalization collapses them to one canonical
2451        // triple and the read side shows one entry.
2452        let one = Owned::new(1, "A", "todo").with_related(2, "B");
2453        let two = Owned::new(2, "B", "todo").with_related(1, "A");
2454        let owned = vec![one, two];
2455        let graph = graph_of(&owned, "done");
2456        assert_eq!(graph.related_to(1), vec![2], "one association, not two");
2457        assert_eq!(graph.related_to(2), vec![1]);
2458        assert_eq!(
2459            normalize_all([(1, 2, RelationKind::Related), (2, 1, RelationKind::Related),]).len(),
2460            1,
2461            "both authorings normalize to one canonical edge"
2462        );
2463    }
2464
2465    // -- review round 3 regressions ---------------------------------------
2466
2467    #[test]
2468    fn a3_no_stored_relationship_id_silently_stops_resolving() {
2469        // Two stored spellings of one association. Both ids must keep working:
2470        // the file is human-editable, so the user still holds both, and a
2471        // dropped row would make one of them a bare `unknown_relation`.
2472        let one = Owned::new(1, "A", "todo").with_related(2, "B");
2473        let two = Owned::new(2, "B", "todo").with_related(1, "A");
2474        let owned = vec![one, two];
2475        let graph = graph_of(&owned, "done");
2476        let ids: Vec<String> = graph
2477            .all_relationships()
2478            .iter()
2479            .filter(|r| r.kind == RelationKind::Related)
2480            .map(|r| r.id.clone())
2481            .collect();
2482        assert_eq!(ids.len(), 2, "both stored rows are retained");
2483        for id in &ids {
2484            assert!(
2485                graph.find_relationship(id).is_some(),
2486                "stored id {id} must still resolve"
2487            );
2488            assert!(
2489                !graph
2490                    .validate_edit(id, None, None)
2491                    .has(DiagnosticKind::UnknownRelation),
2492                "editing by a stored id must not report it unknown"
2493            );
2494        }
2495    }
2496
2497    #[test]
2498    fn a3_the_blocked_adjacency_does_not_repeat_a_neighbour() {
2499        // Redundant rows are kept as rows, but the adjacency is a set.
2500        let mut one = Owned::new(1, "A", "todo");
2501        for _ in 0..3 {
2502            one.push_block(2, "B");
2503        }
2504        let owned = vec![one, Owned::new(2, "B", "todo")];
2505        let graph = graph_of(&owned, "done");
2506        assert_eq!(graph.blocks(1), &[2], "no repeated neighbour");
2507        assert_eq!(graph.blocked_by(2), &[1], "no repeated blocker");
2508        assert_eq!(
2509            graph.blocked_state(2).blockers.len(),
2510            1,
2511            "one blocker is reported once, not once per stored row"
2512        );
2513    }
2514
2515    #[test]
2516    fn c_an_unconfigured_done_column_fails_closed() {
2517        // With no `done_column`, no column means "finished", so a blocker with
2518        // an empty status must still block rather than read as completed.
2519        let mut one = Owned::new(1, "A", "");
2520        one.push_block(2, "B");
2521        let owned = vec![one, Owned::new(2, "B", "todo")];
2522        let graph = graph_of(&owned, "");
2523        assert!(
2524            graph.blocked_state(2).is_blocked,
2525            "an unconfigured completion column must not mark anything done"
2526        );
2527    }
2528
2529    #[test]
2530    fn p1_duplicates_orientation_is_preserved() {
2531        // `duplicates` is directional: these are two distinct claims.
2532        assert_eq!(
2533            normalize_all([
2534                (1, 2, RelationKind::Duplicates),
2535                (2, 1, RelationKind::Duplicates),
2536            ])
2537            .len(),
2538            2
2539        );
2540    }
2541
2542    #[test]
2543    fn p2_an_edit_cannot_land_on_a_triple_another_edge_occupies() {
2544        let mut a = Owned::new(1, "A", "todo");
2545        a.push_block(2, "B");
2546        a.relationships
2547            .push(Relationship::new(1, 2, RelationKind::Related, "B"));
2548        let owned = vec![a, Owned::new(2, "B", "todo")];
2549        let graph = graph_of(&owned, "done");
2550        let blocks_id = graph
2551            .relationships_for(1)
2552            .iter()
2553            .find(|r| r.kind == RelationKind::Blocks)
2554            .expect("a blocks edge")
2555            .id
2556            .clone();
2557
2558        let res = graph.validate_edit(&blocks_id, None, Some(RelationKind::Related));
2559        assert!(
2560            res.has(DiagnosticKind::ConflictingEdge),
2561            "re-kinding onto an occupied triple must be rejected: {res:?}"
2562        );
2563        assert!(
2564            res.errors
2565                .iter()
2566                .any(|d| d.message.contains("would store a second one")),
2567            "the message must name the duplicate case: {res:?}"
2568        );
2569    }
2570
2571    #[test]
2572    fn p2_creating_an_edge_that_already_exists_stays_idempotent() {
2573        // The create path must NOT reject a re-assertion: `tix link` run twice
2574        // has to be a no-op, not a failure (data-model §2).
2575        let a = Owned::new(1, "A", "todo").blocking(2, "B");
2576        let owned = vec![a, Owned::new(2, "B", "todo")];
2577        let graph = graph_of(&owned, "done");
2578        assert!(
2579            graph.validate_create(1, 2, RelationKind::Blocks).is_clean(),
2580            "re-asserting an existing edge is the idempotent path"
2581        );
2582    }
2583
2584    #[test]
2585    fn p3_p4_the_other_endpoint_is_returned_in_both_orientations() {
2586        // Edges authored from the far side, which is the case round 1 missed.
2587        let mut two = Owned::new(2, "B", "todo");
2588        two.relationships
2589            .push(Relationship::new(2, 1, RelationKind::Related, "A"));
2590        two.relationships
2591            .push(Relationship::new(2, 1, RelationKind::Duplicates, "A"));
2592        let owned = vec![Owned::new(1, "A", "todo"), two];
2593        let graph = graph_of(&owned, "done");
2594
2595        // `related` is non-directional: both endpoints see each other.
2596        assert_eq!(graph.related_to(1), vec![2], "never the subject itself");
2597        assert_eq!(graph.related_to(2), vec![1]);
2598        // `duplicates` is directional, so a far-side edge contributes nothing.
2599        assert!(
2600            graph.duplicates_of(1).is_empty(),
2601            "a directional edge pointing at #1 does not mean #1 duplicates it: {:?}",
2602            graph.duplicates_of(1)
2603        );
2604        assert_eq!(graph.duplicates_of(2), vec![1]);
2605    }
2606
2607    #[test]
2608    fn p5_the_blocked_adjacency_never_contains_the_subject() {
2609        // This is the invariant that makes the `blocker != subject` branch in
2610        // the resolution predicate unreachable. If a future change breaks it,
2611        // this fails rather than the branch silently carrying the load.
2612        let mut a = Owned::new(1, "A", "todo");
2613        a.relationships
2614            .push(Relationship::new(1, 1, RelationKind::Blocks, "A"));
2615        a.push_block(2, "B");
2616        let owned = vec![a, Owned::new(2, "B", "todo")];
2617        let graph = graph_of(&owned, "done");
2618        for number in [1, 2] {
2619            assert!(
2620                !graph.blocked_by(number).contains(&number),
2621                "issue #{number} must never appear in its own inbound blocks adjacency"
2622            );
2623        }
2624    }
2625
2626    // -- review round 1 regressions ---------------------------------------
2627
2628    #[test]
2629    fn r1_display_is_refreshed_when_the_target_is_renamed() {
2630        // The edge was created while #2 was titled "Old title".
2631        let a = Owned::new(1, "A", "todo").blocking(2, "Old title");
2632        let owned = vec![a, Owned::new(2, "New title", "todo")];
2633        let graph = graph_of(&owned, "done");
2634        assert_eq!(
2635            graph.relationships_for(1)[0].display,
2636            "blocks #2 New title",
2637            "a persisted display must not outlive the title it was built from"
2638        );
2639    }
2640
2641    #[test]
2642    fn r1_a_dangling_edge_keeps_its_persisted_display() {
2643        // No target, so there is no title to rebuild the label from.
2644        let mut a = Owned::new(1, "A", "todo");
2645        a.relationships
2646            .push(Relationship::new(1, 404, RelationKind::Blocks, "gone"));
2647        let owned = vec![a];
2648        assert_eq!(
2649            graph_of(&owned, "done").relationships_for(1)[0].display,
2650            "blocks #404 gone"
2651        );
2652    }
2653
2654    #[test]
2655    fn r2_a_self_link_is_reported_once_not_twice() {
2656        let mut a = Owned::new(1, "A", "todo");
2657        a.relationships
2658            .push(Relationship::new(1, 1, RelationKind::Blocks, "A"));
2659        let owned = vec![a];
2660        let graph = graph_of(&owned, "done");
2661        assert_eq!(graph.edge_count(), 1);
2662        assert_eq!(
2663            graph.relationships_for(1).len(),
2664            1,
2665            "a self-link has one endpoint, so it must be registered once"
2666        );
2667    }
2668
2669    #[test]
2670    fn r3_an_issue_is_never_blocked_by_itself() {
2671        let mut a = Owned::new(1, "A", "todo");
2672        a.relationships
2673            .push(Relationship::new(1, 1, RelationKind::Blocks, "A"));
2674        let owned = vec![a];
2675        let graph = graph_of(&owned, "done");
2676
2677        // Still visible as invalid data...
2678        assert!(graph.validate_snapshot().has(DiagnosticKind::SelfLink));
2679        // ...but never as a real edge, so it cannot make #1 its own blocker.
2680        assert_eq!(graph.blocks(1), [] as [u32; 0]);
2681        let state = graph.blocked_state(1);
2682        assert!(!state.is_blocked, "an issue must not block itself");
2683        assert_eq!(state.reason, "");
2684    }
2685
2686    #[test]
2687    fn r4_validate_edit_does_not_hide_a_pre_existing_conflict() {
2688        // #1 -> #2 blocks, plus #1 -> #2 related: the data is already corrupt.
2689        let mut a = Owned::new(1, "A", "todo");
2690        a.push_block(2, "B");
2691        a.relationships
2692            .push(Relationship::new(1, 2, RelationKind::Related, "B"));
2693        let owned = vec![a, Owned::new(2, "B", "todo")];
2694        let graph = graph_of(&owned, "done");
2695        let blocks_id = graph
2696            .relationships_for(1)
2697            .iter()
2698            .find(|r| r.kind == RelationKind::Blocks)
2699            .expect("a blocks edge")
2700            .id
2701            .clone();
2702
2703        assert!(
2704            graph
2705                .validate_snapshot()
2706                .has(DiagnosticKind::ConflictingEdge)
2707        );
2708        let edit = graph.validate_edit(&blocks_id, None, None);
2709        assert!(
2710            edit.has(DiagnosticKind::ConflictingEdge),
2711            "editing an unrelated field must not return a clean verdict on corrupt data: {edit:?}"
2712        );
2713    }
2714
2715    #[test]
2716    fn r4_an_edit_does_not_conflict_with_itself() {
2717        let a = Owned::new(1, "A", "todo").blocking(2, "B");
2718        let owned = vec![a, Owned::new(2, "B", "todo"), Owned::new(3, "C", "todo")];
2719        let graph = graph_of(&owned, "done");
2720        let id = graph.relationships_for(1)[0].id.clone();
2721
2722        assert!(
2723            graph.validate_edit(&id, None, None).is_clean(),
2724            "an edge never conflicts with itself"
2725        );
2726        assert!(
2727            graph.validate_edit(&id, Some(3), None).is_clean(),
2728            "retargeting to an unrelated issue is not a conflict"
2729        );
2730    }
2731
2732    // -- diagnostics --------------------------------------------------------
2733
2734    #[test]
2735    fn every_contract_diagnostic_kind_exists_with_its_exact_name() {
2736        let names: Vec<&str> = [
2737            DiagnosticKind::SelfLink,
2738            DiagnosticKind::MissingTarget,
2739            DiagnosticKind::ConflictingEdge,
2740            DiagnosticKind::Cycle,
2741            DiagnosticKind::UnknownKind,
2742            DiagnosticKind::UnknownRelation,
2743            DiagnosticKind::RetiredLinksKey,
2744        ]
2745        .iter()
2746        .map(|k| k.as_str())
2747        .collect();
2748        assert_eq!(
2749            names,
2750            [
2751                "self_link",
2752                "missing_target",
2753                "conflicting_edge",
2754                "cycle",
2755                "unknown_kind",
2756                "unknown_relation",
2757                "retired_links_key"
2758            ]
2759        );
2760    }
2761
2762    #[test]
2763    fn validate_snapshot_never_returns_an_err() {
2764        // The signature itself is the assertion: there is no Result to unwrap.
2765        let owned = vec![Owned::new(1, "A", "todo").with_retired_links_key()];
2766        let result: RelationshipValidationResult = graph_of(&owned, "done").validate_snapshot();
2767        assert!(result.blocking());
2768    }
2769
2770    #[test]
2771    fn diagnostics_sort_deterministically() {
2772        let mut a = RelationshipValidationResult::with(Diagnostic::self_link(1));
2773        a.merge(RelationshipValidationResult::with(
2774            Diagnostic::unknown_relation("rel_x"),
2775        ));
2776        let mut b = a.clone();
2777        b.sort_diagnostics();
2778        a.sort_diagnostics();
2779        assert_eq!(a, b);
2780    }
2781
2782    #[test]
2783    fn blocked_reason_is_shared_by_every_surface() {
2784        let single = vec![ChainNode::new(1, "A")];
2785        assert_eq!(blocked_reason(&single), "Blocked by #1 A");
2786        let many = vec![ChainNode::new(1, "A"), ChainNode::new(2, "B")];
2787        assert_eq!(blocked_reason(&many), "Blocked by 2 issues: #1 A, #2 B");
2788    }
2789
2790    #[test]
2791    fn cycle_paths_are_reported_once_regardless_of_starting_node() {
2792        let a = Owned::new(1, "A", "todo").blocking(2, "B");
2793        let b = Owned::new(2, "B", "todo").blocking(3, "C");
2794        let c = Owned::new(3, "C", "todo").blocking(1, "A");
2795        let owned = vec![a, b, c];
2796        let result = graph_of(&owned, "done").validate_snapshot();
2797        let cycles: Vec<&Diagnostic> = result
2798            .errors
2799            .iter()
2800            .filter(|d| d.kind == DiagnosticKind::Cycle)
2801            .collect();
2802        assert_eq!(
2803            cycles.len(),
2804            1,
2805            "one distinct cycle, one diagnostic: {result:?}"
2806        );
2807    }
2808
2809    #[test]
2810    fn hand_edited_blocked_by_persisted_edges_still_block() {
2811        // A hand-edited file that persisted the `blocked_by` spelling must not
2812        // silently drop out of blocked state. `from: 1, to: 2, blocked_by` is
2813        // "1 is blocked by 2", so it normalizes to 2 blocking 1.
2814        let mut owner = Owned::new(2, "B", "todo");
2815        owner.relationships.push(Relationship {
2816            id: "rel_hand".to_string(),
2817            from: 1,
2818            to: 2,
2819            kind: RelationKind::BlockedBy,
2820            display: String::new(),
2821        });
2822        let owned = vec![Owned::new(1, "A", "todo"), owner];
2823        let graph = graph_of(&owned, "done");
2824        assert!(
2825            graph.blocked_state(1).is_blocked,
2826            "the normalized adjacency must still carry the edge"
2827        );
2828        assert_eq!(graph.blocks(2), &[1], "stored in canonical blocks form");
2829    }
2830}