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/// Crockford base32 alphabet: no `I`, `L`, `O`, or `U`.
1686const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
1687
1688/// Generate a stable relationship identifier.
1689///
1690/// The format is `rel_` followed by 26 Crockford base32 characters: a 48-bit
1691/// millisecond timestamp followed by 80 pseudo-random bits. It is ULID-shaped
1692/// so ids sort by creation time and stay stable across retarget, rename, and
1693/// export/import.
1694///
1695/// Randomness comes from the OS-seeded hasher state plus a process-local
1696/// counter, so the crate keeps **zero new dependencies** (C-001).
1697pub fn generate_relationship_id() -> RelationshipId {
1698    use std::hash::{BuildHasher, Hasher};
1699    use std::sync::atomic::{AtomicU64, Ordering};
1700    use std::time::{SystemTime, UNIX_EPOCH};
1701
1702    static COUNTER: AtomicU64 = AtomicU64::new(0);
1703
1704    let millis = SystemTime::now()
1705        .duration_since(UNIX_EPOCH)
1706        .map(|d| d.as_millis())
1707        .unwrap_or(0);
1708    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
1709    let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
1710    hasher.write_u128(millis);
1711    hasher.write_u64(counter);
1712    let entropy = hasher.finish();
1713    // 80 bits: 64 from the hasher, 16 more from a second pass over the counter.
1714    let mut second = std::collections::hash_map::RandomState::new().build_hasher();
1715    second.write_u64(entropy);
1716    second.write_u64(counter.wrapping_mul(0x9E37_79B9_7F4A_7C15));
1717    let high = second.finish() & 0xFFFF;
1718
1719    let value = (millis << 80u32) | (u128::from(entropy) << 16) | u128::from(high);
1720    format!("rel_{}", encode_crockford(value))
1721}
1722
1723/// Encode the low 130 bits of `value` as 26 Crockford base32 characters.
1724fn encode_crockford(value: u128) -> String {
1725    let mut out = [b'0'; 26];
1726    let mut remaining = value;
1727    for slot in out.iter_mut().rev() {
1728        *slot = CROCKFORD[(remaining & 0x1F) as usize];
1729        remaining >>= 5;
1730    }
1731    String::from_utf8_lossy(&out).into_owned()
1732}
1733
1734// ---------------------------------------------------------------------------
1735// Tests
1736// ---------------------------------------------------------------------------
1737
1738#[cfg(test)]
1739mod tests {
1740    use super::*;
1741
1742    // -- test fixtures -----------------------------------------------------
1743
1744    /// An owned issue, so tests can build borrowed [`IssueView`]s from a
1745    /// stable backing allocation.
1746    #[derive(Clone)]
1747    struct Owned {
1748        number: u32,
1749        title: String,
1750        status: String,
1751        relationships: Vec<Relationship>,
1752        retired: bool,
1753    }
1754
1755    impl Owned {
1756        fn new(number: u32, title: &str, status: &str) -> Self {
1757            Self {
1758                number,
1759                title: title.to_string(),
1760                status: status.to_string(),
1761                relationships: Vec::new(),
1762                retired: false,
1763            }
1764        }
1765
1766        /// Consuming builder: this issue blocks `to`.
1767        fn blocking(mut self, to: u32, title: &str) -> Self {
1768            self.relationships.push(Relationship::new(
1769                self.number,
1770                to,
1771                RelationKind::Blocks,
1772                title,
1773            ));
1774            self
1775        }
1776
1777        /// In-place builder, for loops that build many edges on one issue.
1778        fn push_block(&mut self, to: u32, title: &str) {
1779            self.relationships.push(Relationship::new(
1780                self.number,
1781                to,
1782                RelationKind::Blocks,
1783                title,
1784            ));
1785        }
1786
1787        /// This issue is `related` to `to`.
1788        fn with_related(self, to: u32, title: &str) -> Self {
1789            let mut this = self;
1790            this.relationships.push(Relationship::new(
1791                this.number,
1792                to,
1793                RelationKind::Related,
1794                title,
1795            ));
1796            this
1797        }
1798
1799        fn with_retired_links_key(mut self) -> Self {
1800            self.retired = true;
1801            self
1802        }
1803
1804        fn view(&self) -> IssueView<'_> {
1805            IssueView::new(self.number, &self.title, &self.status, &self.relationships)
1806                .with_retired_links_key(self.retired)
1807        }
1808    }
1809
1810    fn graph_of<'x>(owned: &'x [Owned], done_column: &str) -> RelationshipGraph<'x> {
1811        let views: Vec<IssueView<'x>> = owned.iter().map(Owned::view).collect();
1812        RelationshipGraph::new(&views, done_column)
1813    }
1814
1815    // -- T001: vocabulary --------------------------------------------------
1816
1817    #[test]
1818    fn relation_kind_has_exactly_four_variants_with_canonical_spellings() {
1819        let spellings: Vec<&str> = RelationKind::ALL.iter().map(|k| k.as_str()).collect();
1820        assert_eq!(spellings, ["blocks", "blocked_by", "duplicates", "related"]);
1821    }
1822
1823    #[test]
1824    fn is_directional_is_false_only_for_related() {
1825        for kind in RelationKind::ALL {
1826            assert_eq!(
1827                kind.is_directional(),
1828                kind != RelationKind::Related,
1829                "{kind} directionality"
1830            );
1831        }
1832    }
1833
1834    #[test]
1835    fn inverse_truth_table_matches_the_contract() {
1836        assert_eq!(
1837            RelationKind::Blocks.inverse(),
1838            Some(RelationKind::BlockedBy)
1839        );
1840        assert_eq!(
1841            RelationKind::BlockedBy.inverse(),
1842            Some(RelationKind::Blocks)
1843        );
1844        assert_eq!(RelationKind::Duplicates.inverse(), None);
1845        assert_eq!(RelationKind::Related.inverse(), None);
1846    }
1847
1848    #[test]
1849    fn from_str_parses_every_canonical_spelling() {
1850        for kind in RelationKind::ALL {
1851            assert_eq!(kind.as_str().parse::<RelationKind>().unwrap(), kind);
1852        }
1853    }
1854
1855    #[test]
1856    fn unknown_kind_is_rejected_and_never_coerced_to_related() {
1857        let err = "depends_on".parse::<RelationKind>().unwrap_err();
1858        assert_eq!(err.kind, DiagnosticKind::UnknownKind);
1859        assert!(err.message.contains("depends_on"));
1860        assert!(err.message.contains("blocks"));
1861    }
1862
1863    #[test]
1864    fn relationship_serde_round_trip_is_stable() {
1865        let rel = Relationship::new(42, 51, RelationKind::Blocks, "Refactor store");
1866        let yaml = serde_yaml::to_string(&rel).unwrap();
1867        let parsed: Relationship = serde_yaml::from_str(&yaml).unwrap();
1868        assert_eq!(rel, parsed);
1869        assert_eq!(serde_yaml::to_string(&parsed).unwrap(), yaml);
1870    }
1871
1872    #[test]
1873    fn relationship_parses_with_display_omitted() {
1874        let yaml = "id: rel_1\nfrom: 1\nto: 2\nkind: blocks\n";
1875        let rel: Relationship = serde_yaml::from_str(yaml).unwrap();
1876        assert_eq!(rel.display, "");
1877        // An empty display is omitted rather than written as `display: ""`.
1878        assert!(!serde_yaml::to_string(&rel).unwrap().contains("display"));
1879    }
1880
1881    #[test]
1882    fn display_name_combines_kind_number_and_title() {
1883        assert_eq!(
1884            display_name(RelationKind::Blocks, 42, "Fix flaky test"),
1885            "blocks #42 Fix flaky test"
1886        );
1887        assert_eq!(
1888            display_name(RelationKind::Related, 7, "   "),
1889            "related #7",
1890            "a blank title must not leave a trailing space"
1891        );
1892    }
1893
1894    #[test]
1895    fn stable_ids_are_unique_across_a_batch_and_prefixed() {
1896        let mut seen = std::collections::HashSet::new();
1897        for _ in 0..2_000 {
1898            let id = generate_relationship_id();
1899            assert!(id.starts_with("rel_"), "unexpected id shape: {id}");
1900            assert_eq!(id.len(), "rel_".len() + 26, "unexpected id length: {id}");
1901            assert!(
1902                id["rel_".len()..].bytes().all(|b| CROCKFORD.contains(&b)),
1903                "non-Crockford character in {id}"
1904            );
1905            assert!(seen.insert(id), "duplicate relationship id generated");
1906        }
1907    }
1908
1909    // -- T002: direction convention ----------------------------------------
1910
1911    #[test]
1912    fn both_spellings_of_a_blocking_pair_normalize_to_one_canonical_edge() {
1913        let forward = normalize(1, 2, RelationKind::Blocks);
1914        let inverse = normalize(1, 2, RelationKind::BlockedBy);
1915        assert_eq!(
1916            forward,
1917            CanonicalEdge {
1918                from: 1,
1919                to: 2,
1920                kind: RelationKind::Blocks
1921            }
1922        );
1923        assert_eq!(
1924            inverse,
1925            CanonicalEdge {
1926                from: 2,
1927                to: 1,
1928                kind: RelationKind::Blocks
1929            },
1930            "blocked_by must swap the endpoints into the canonical blocks form"
1931        );
1932        assert_eq!(
1933            forward.to, inverse.from,
1934            "the two spellings are the same edge"
1935        );
1936    }
1937
1938    #[test]
1939    fn a_persisted_blocking_edge_never_carries_the_blocked_by_kind() {
1940        let stored = Relationship::new(1, 2, RelationKind::BlockedBy, "T");
1941        assert_eq!(stored.kind, RelationKind::Blocks);
1942        assert_eq!((stored.from, stored.to), (2, 1));
1943    }
1944
1945    #[test]
1946    fn normalize_all_deduplicates_both_spellings_and_is_idempotent() {
1947        // "1 blocks 2" and "2 is blocked by 1" are one edge.
1948        let authored = [
1949            (1, 2, RelationKind::Blocks),
1950            (2, 1, RelationKind::BlockedBy),
1951            (3, 4, RelationKind::Related),
1952        ];
1953        let once = normalize_all(authored);
1954        assert_eq!(once.len(), 2, "the two spellings collapse to one edge");
1955        assert_eq!(
1956            once,
1957            normalize_all(authored),
1958            "normalization is deterministic"
1959        );
1960        assert_eq!(
1961            once,
1962            normalize_all(
1963                once.iter()
1964                    .map(|e| (e.from, e.to, e.kind))
1965                    .collect::<Vec<_>>()
1966            )
1967        );
1968    }
1969
1970    #[test]
1971    fn related_and_duplicates_never_swap_endpoints() {
1972        for kind in [RelationKind::Related, RelationKind::Duplicates] {
1973            let edge = normalize(1, 2, kind);
1974            assert_eq!((edge.from, edge.to, edge.kind), (1, 2, kind));
1975        }
1976    }
1977
1978    #[test]
1979    fn a_reciprocal_blocking_pair_is_rejected_as_a_cycle() {
1980        let a = Owned::new(1, "A", "todo").blocking(2, "B");
1981        let b = Owned::new(2, "B", "todo").blocking(1, "A");
1982        let owned = vec![a, b];
1983        let graph = graph_of(&owned, "done");
1984
1985        let result = graph.validate_snapshot();
1986        assert!(
1987            result.has(DiagnosticKind::Cycle),
1988            "expected a cycle diagnostic: {result:?}"
1989        );
1990        assert!(result.blocking(), "a reciprocal pair must be blocking");
1991    }
1992
1993    #[test]
1994    fn a_conflicting_kind_for_the_same_pair_is_reported() {
1995        let a = Owned::new(1, "A", "todo").blocking(2, "B");
1996        let mut a = a;
1997        a.relationships
1998            .push(Relationship::new(1, 2, RelationKind::Related, "B"));
1999        let owned = vec![a, Owned::new(2, "B", "todo")];
2000        let result = graph_of(&owned, "done").validate_snapshot();
2001        assert!(
2002            result.has(DiagnosticKind::ConflictingEdge),
2003            "expected conflicting_edge: {result:?}"
2004        );
2005    }
2006
2007    // -- T003: graph build and ordering ------------------------------------
2008
2009    #[test]
2010    fn edges_are_returned_from_both_endpoints() {
2011        let a = Owned::new(1, "A", "todo").blocking(2, "B");
2012        let owned = vec![a, Owned::new(2, "B", "todo")];
2013        let graph = graph_of(&owned, "done");
2014        assert_eq!(graph.relationships_for(1).len(), 1);
2015        assert_eq!(
2016            graph.relationships_for(2).len(),
2017            1,
2018            "the target sees the edge too"
2019        );
2020    }
2021
2022    #[test]
2023    fn ordering_is_stable_across_repeated_builds() {
2024        let a = Owned::new(1, "A", "todo")
2025            .blocking(4, "D")
2026            .blocking(2, "B")
2027            .blocking(3, "C");
2028        let owned = vec![
2029            a,
2030            Owned::new(2, "B", "todo"),
2031            Owned::new(3, "C", "todo"),
2032            Owned::new(4, "D", "todo"),
2033        ];
2034        let first: Vec<(RelationKind, u32)> = graph_of(&owned, "done")
2035            .relationships_for(1)
2036            .iter()
2037            .map(|r| (r.kind, r.to))
2038            .collect();
2039        let second: Vec<(RelationKind, u32)> = graph_of(&owned, "done")
2040            .relationships_for(1)
2041            .iter()
2042            .map(|r| (r.kind, r.to))
2043            .collect();
2044        assert_eq!(first, second, "repeated builds must be byte-comparable");
2045        assert_eq!(
2046            first,
2047            vec![
2048                (RelationKind::Blocks, 2),
2049                (RelationKind::Blocks, 3),
2050                (RelationKind::Blocks, 4)
2051            ],
2052            "edges are ordered by (kind, target)"
2053        );
2054    }
2055
2056    #[test]
2057    fn related_and_duplicates_never_enter_blocked_adjacency() {
2058        let mut a = Owned::new(1, "A", "todo");
2059        a.relationships
2060            .push(Relationship::new(1, 2, RelationKind::Related, "B"));
2061        a.relationships
2062            .push(Relationship::new(1, 3, RelationKind::Duplicates, "C"));
2063        let owned = vec![a, Owned::new(2, "B", "todo"), Owned::new(3, "C", "todo")];
2064        let graph = graph_of(&owned, "done");
2065        assert!(
2066            graph.blocks(1).is_empty(),
2067            "related/duplicates are not blockers"
2068        );
2069        assert!(graph.blocked_by(2).is_empty());
2070        assert!(graph.blocked_by(3).is_empty());
2071        assert_eq!(graph.duplicates_of(1), vec![3]);
2072        assert_eq!(graph.related_to(1), vec![2]);
2073    }
2074
2075    #[test]
2076    fn an_issue_with_no_edges_yields_empty_results_not_an_error() {
2077        let owned = vec![Owned::new(1, "A", "todo")];
2078        let graph = graph_of(&owned, "done");
2079        assert!(graph.relationships_for(1).is_empty());
2080        assert!(graph.blocks(1).is_empty());
2081        assert!(graph.blocked_by(1).is_empty());
2082        assert!(graph.open_blockers(1).is_empty());
2083        assert!(graph.dependencies_are_empty_for_unknown_issue());
2084    }
2085
2086    impl RelationshipGraph<'_> {
2087        /// Small readability helper for the empty-result test.
2088        fn dependencies_are_empty_for_unknown_issue(&self) -> bool {
2089            self.dependency_chain(9_999).paths.is_empty()
2090        }
2091    }
2092
2093    // -- T004: validation --------------------------------------------------
2094
2095    #[test]
2096    fn an_empty_snapshot_validates_clean() {
2097        let owned = vec![Owned::new(1, "A", "todo")];
2098        let result = graph_of(&owned, "done").validate_snapshot();
2099        assert!(result.is_clean(), "unexpected diagnostics: {result:?}");
2100        assert!(!result.blocking());
2101    }
2102
2103    #[test]
2104    fn self_link_create_is_rejected() {
2105        let owned = vec![Owned::new(1, "A", "todo")];
2106        let result = graph_of(&owned, "done").validate_create(1, 1, RelationKind::Blocks);
2107        assert!(result.has(DiagnosticKind::SelfLink), "{result:?}");
2108        assert!(result.blocking());
2109    }
2110
2111    #[test]
2112    fn missing_target_create_is_rejected() {
2113        let owned = vec![Owned::new(1, "A", "todo")];
2114        let result = graph_of(&owned, "done").validate_create(1, 99, RelationKind::Blocks);
2115        assert!(result.has(DiagnosticKind::MissingTarget), "{result:?}");
2116    }
2117
2118    #[test]
2119    fn validate_create_accepts_both_spellings_of_the_same_pair() {
2120        let owned = vec![Owned::new(1, "A", "todo"), Owned::new(2, "B", "todo")];
2121        let graph = graph_of(&owned, "done");
2122        assert!(graph.validate_create(1, 2, RelationKind::Blocks).is_clean());
2123        assert!(
2124            graph
2125                .validate_create(1, 2, RelationKind::BlockedBy)
2126                .is_clean(),
2127            "the inverse spelling is the same edge, not a conflict"
2128        );
2129    }
2130
2131    #[test]
2132    fn editing_an_unknown_relationship_id_is_reported() {
2133        let owned = vec![Owned::new(1, "A", "todo")];
2134        let result = graph_of(&owned, "done").validate_edit("rel_missing", None, None);
2135        assert!(result.has(DiagnosticKind::UnknownRelation), "{result:?}");
2136    }
2137
2138    #[test]
2139    fn a_retired_links_key_is_reported_and_never_read_as_typed_data() {
2140        let owned = vec![
2141            Owned::new(1, "A", "todo").with_retired_links_key(),
2142            Owned::new(2, "B", "todo"),
2143        ];
2144        let result = graph_of(&owned, "done").validate_snapshot();
2145        assert!(result.has(DiagnosticKind::RetiredLinksKey), "{result:?}");
2146        assert_eq!(
2147            result
2148                .errors
2149                .iter()
2150                .find(|d| d.kind == DiagnosticKind::RetiredLinksKey)
2151                .map(|d| d.field.as_str()),
2152            Some("links")
2153        );
2154    }
2155
2156    #[test]
2157    fn a_snapshot_with_three_distinct_problems_reports_exactly_three_diagnostics() {
2158        let mut self_linked = Owned::new(1, "A", "todo");
2159        self_linked
2160            .relationships
2161            .push(Relationship::new(3, 3, RelationKind::Blocks, "C"));
2162        let mut dangling = Owned::new(2, "B", "todo");
2163        dangling
2164            .relationships
2165            .push(Relationship::new(2, 404, RelationKind::Blocks, "gone"));
2166        let owned = vec![
2167            self_linked,
2168            dangling,
2169            Owned::new(3, "C", "todo").with_retired_links_key(),
2170        ];
2171        let result = graph_of(&owned, "done").validate_snapshot();
2172        assert_eq!(
2173            result.errors.len(),
2174            3,
2175            "expected no duplicate or implied reports: {result:?}"
2176        );
2177    }
2178
2179    // -- T005: cycles, chains, blocked state -------------------------------
2180
2181    #[test]
2182    fn a_three_chain_returns_the_ordered_path() {
2183        let a = Owned::new(1, "A", "todo").blocking(2, "B");
2184        let b = Owned::new(2, "B", "todo").blocking(3, "C");
2185        let owned = vec![a, b, Owned::new(3, "C", "todo")];
2186        let chain = graph_of(&owned, "done").dependency_chain(1);
2187        assert_eq!(chain.root, 1);
2188        assert_eq!(chain.paths.len(), 1);
2189        assert_eq!(
2190            chain.paths[0].iter().map(|n| n.number).collect::<Vec<_>>(),
2191            vec![1, 2, 3],
2192            "the chain walks upstream from the root toward its blockers"
2193        );
2194        assert!(!chain.truncated);
2195    }
2196
2197    #[test]
2198    fn a_cycle_closing_edge_is_rejected() {
2199        let a = Owned::new(1, "A", "todo").blocking(2, "B");
2200        let b = Owned::new(2, "B", "todo").blocking(3, "C");
2201        let owned = vec![a, b, Owned::new(3, "C", "todo")];
2202        let result = graph_of(&owned, "done").validate_create(3, 1, RelationKind::Blocks);
2203        assert!(result.has(DiagnosticKind::Cycle), "{result:?}");
2204        assert!(result.blocking());
2205    }
2206
2207    #[test]
2208    fn blocked_state_is_true_only_for_an_open_inbound_blocks_edge() {
2209        let a = Owned::new(1, "A", "todo").blocking(2, "B");
2210        let owned = vec![a, Owned::new(2, "B", "todo")];
2211        let graph = graph_of(&owned, "done");
2212
2213        let state = graph.blocked_state(2);
2214        assert!(state.is_blocked);
2215        assert_eq!(
2216            state.blockers.iter().map(|n| n.number).collect::<Vec<_>>(),
2217            vec![1]
2218        );
2219        assert!(
2220            state.reason.contains("#1"),
2221            "the reason names the blocker: {}",
2222            state.reason
2223        );
2224
2225        assert!(
2226            !graph.blocked_state(1).is_blocked,
2227            "the blocker is not itself blocked"
2228        );
2229    }
2230
2231    #[test]
2232    fn moving_the_blocker_into_the_configured_done_column_clears_blocked_state() {
2233        let a = Owned::new(1, "A", "todo").blocking(2, "B");
2234        let owned = vec![a, Owned::new(2, "B", "todo")];
2235        // A board whose completion column is "shipped", not "done".
2236        let graph = graph_of(&owned, "shipped");
2237        assert!(
2238            graph.blocked_state(2).is_blocked,
2239            "A is not in the shipped column yet"
2240        );
2241
2242        let mut moved = owned.clone();
2243        moved[0].status = "shipped".to_string();
2244        let after = graph_of(&moved, "shipped");
2245        assert!(
2246            !after.blocked_state(2).is_blocked,
2247            "a blocker in the configured done_column stops blocking"
2248        );
2249    }
2250
2251    #[test]
2252    fn a_blocker_in_a_non_completion_column_keeps_blocking() {
2253        let a = Owned::new(1, "A", "in review").blocking(2, "B");
2254        let owned = vec![a, Owned::new(2, "B", "todo")];
2255        let graph = graph_of(&owned, "shipped");
2256        assert!(graph.blocked_state(2).is_blocked);
2257        assert!(!graph.blocked_state(2).reason.is_empty());
2258    }
2259
2260    #[test]
2261    fn a_dangling_edge_is_diagnosed_but_does_not_freeze_the_issue() {
2262        // Edge 1 -> 404 where 404 does not exist: validation reports it, but the
2263        // blocked-state derivation must exclude it.
2264        let mut blocker = Owned::new(1, "A", "todo");
2265        blocker
2266            .relationships
2267            .push(Relationship::new(1, 404, RelationKind::Blocks, "gone"));
2268        let owned = vec![blocker, Owned::new(2, "B", "todo")];
2269        let graph = graph_of(&owned, "done");
2270
2271        let result = graph.validate_snapshot();
2272        assert!(
2273            result.has(DiagnosticKind::MissingTarget),
2274            "the dangling edge is still reported: {result:?}"
2275        );
2276        assert!(
2277            !graph.blocked_state(2).is_blocked,
2278            "invalid data must not permanently freeze an issue"
2279        );
2280    }
2281
2282    #[test]
2283    fn a_hand_edited_cyclic_file_terminates_instead_of_hanging() {
2284        // Write-time rejection makes this unreachable, but a hand-edited file
2285        // must not hang the TUI.
2286        let a = Owned::new(1, "A", "todo").blocking(2, "B");
2287        let b = Owned::new(2, "B", "todo").blocking(1, "A");
2288        let owned = vec![a, b];
2289        let graph = graph_of(&owned, "done");
2290
2291        assert!(graph.validate_snapshot().has(DiagnosticKind::Cycle));
2292        let chain = graph.dependency_chain(1);
2293        assert!(
2294            chain.paths.iter().all(|p| p.len() <= owned.len()),
2295            "the visited-set guard must bound every path"
2296        );
2297    }
2298
2299    // -- NFR-001: linear single pass ---------------------------------------
2300
2301    #[test]
2302    fn a_ten_thousand_edge_graph_validates_in_one_pass() {
2303        const ISSUES: u32 = 2_000;
2304        const PER_ISSUE: u32 = 6;
2305        // Forward-only edges (lower index blocks higher) keep the graph acyclic,
2306        // so a clean verdict is the correct expectation.
2307        let mut owned: Vec<Owned> = (0..ISSUES)
2308            .map(|n| Owned::new(n + 1, &format!("Issue {n}"), "todo"))
2309            .collect();
2310        for n in 0..ISSUES {
2311            for k in 1..=PER_ISSUE {
2312                let target = n + k;
2313                if target < ISSUES {
2314                    owned[n as usize].push_block(target + 1, "Target");
2315                }
2316            }
2317        }
2318        let graph = graph_of(&owned, "done");
2319        assert!(
2320            graph.edge_count() >= 10_000,
2321            "expected a 10,000-edge graph, built {}",
2322            graph.edge_count()
2323        );
2324
2325        // Measure structure (pass completed, correct result) rather than a
2326        // wall-clock bound so the suite does not go flaky on shared CI hardware.
2327        let result = graph.validate_snapshot();
2328        assert!(
2329            result.is_clean(),
2330            "a well-formed graph validates clean: {result:?}"
2331        );
2332        assert_eq!(graph.issue_count(), ISSUES as usize);
2333        assert!(graph.blocked_state(2).is_blocked);
2334    }
2335
2336    #[test]
2337    fn a_ten_thousand_node_chain_does_not_overflow_the_stack() {
2338        const DEPTH: u32 = 10_000;
2339        let mut owned: Vec<Owned> = (0..DEPTH)
2340            .map(|n| Owned::new(n + 1, &format!("Node {n}"), "todo"))
2341            .collect();
2342        for n in 0..DEPTH - 1 {
2343            owned[n as usize].push_block(n + 2, "Next");
2344        }
2345        let graph = graph_of(&owned, "done");
2346        // Iterative traversal: a recursive DFS would blow the stack here.
2347        assert!(graph.validate_snapshot().is_clean());
2348        let chain = graph.dependency_chain(1);
2349        assert!(chain.truncated, "the defensive node bound engages");
2350        assert!(
2351            graph
2352                .cycle_if_added(CanonicalEdge {
2353                    from: DEPTH,
2354                    to: 1,
2355                    kind: RelationKind::Blocks
2356                })
2357                .is_some()
2358        );
2359    }
2360
2361    // -- review round 4 regression -----------------------------------------
2362
2363    #[test]
2364    fn d1_inverse_spellings_find_the_same_row() {
2365        // Stored: 1 blocks 2.
2366        let owned = vec![
2367            Owned::new(1, "A", "todo").blocking(2, "B"),
2368            Owned::new(2, "B", "todo"),
2369        ];
2370        let graph = graph_of(&owned, "done");
2371        let forward = graph
2372            .find_canonical(1, 2, RelationKind::Blocks)
2373            .map(|r| r.id.clone());
2374        let inverse = graph
2375            .find_canonical(2, 1, RelationKind::BlockedBy)
2376            .map(|r| r.id.clone());
2377        assert!(forward.is_some(), "the stored edge must be found");
2378        assert_eq!(
2379            forward, inverse,
2380            "1 blocks 2 and 2 blocked_by 1 name one relationship"
2381        );
2382        assert_ne!(
2383            forward,
2384            graph
2385                .find_canonical(1, 2, RelationKind::BlockedBy)
2386                .map(|r| r.id.clone()),
2387            "1 blocked_by 2 is the inverse of the stored edge, so it must not match"
2388        );
2389    }
2390
2391    #[test]
2392    fn d1_related_is_found_from_either_orientation() {
2393        let mut two = Owned::new(2, "B", "todo");
2394        two.relationships
2395            .push(Relationship::new(2, 1, RelationKind::Related, "A"));
2396        let owned = vec![Owned::new(1, "A", "todo"), two];
2397        let graph = graph_of(&owned, "done");
2398        let a = graph
2399            .find_canonical(1, 2, RelationKind::Related)
2400            .map(|r| r.id.clone());
2401        let b = graph
2402            .find_canonical(2, 1, RelationKind::Related)
2403            .map(|r| r.id.clone());
2404        assert!(a.is_some(), "related is order-normalized, so it is found");
2405        assert_eq!(a, b);
2406    }
2407
2408    #[test]
2409    fn d1_a_self_link_or_unrelated_pair_does_not_match() {
2410        let owned = vec![
2411            Owned::new(1, "A", "todo").blocking(2, "B"),
2412            Owned::new(2, "B", "todo"),
2413            Owned::new(3, "C", "todo"),
2414        ];
2415        let graph = graph_of(&owned, "done");
2416        assert!(graph.find_canonical(1, 1, RelationKind::Blocks).is_none());
2417        assert!(graph.find_canonical(1, 3, RelationKind::Blocks).is_none());
2418        assert!(graph.find_canonical(3, 1, RelationKind::Blocks).is_none());
2419        assert!(graph.find_canonical(1, 2, RelationKind::Related).is_none());
2420        assert!(
2421            graph.find_canonical(1, 2, RelationKind::Blocks).is_some(),
2422            "the real edge still resolves"
2423        );
2424    }
2425
2426    #[test]
2427    fn d1_redundant_rows_resolve_to_the_same_row_every_time() {
2428        let one = Owned::new(1, "A", "todo").with_related(2, "B");
2429        let two = Owned::new(2, "B", "todo").with_related(1, "A");
2430        let owned = vec![one, two];
2431        let first = graph_of(&owned, "done")
2432            .find_canonical(1, 2, RelationKind::Related)
2433            .map(|r| r.id.clone());
2434        let second = graph_of(&owned, "done")
2435            .find_canonical(1, 2, RelationKind::Related)
2436            .map(|r| r.id.clone());
2437        assert!(first.is_some());
2438        assert_eq!(first, second, "the answer must be stable, not arbitrary");
2439    }
2440
2441    #[test]
2442    fn d1_an_unknown_pair_returns_none() {
2443        let owned = vec![Owned::new(1, "A", "todo")];
2444        let graph = graph_of(&owned, "done");
2445        assert!(graph.find_canonical(1, 404, RelationKind::Blocks).is_none());
2446        assert!(graph.find_canonical(404, 1, RelationKind::Blocks).is_none());
2447    }
2448
2449    // -- review round 2 regressions ---------------------------------------
2450
2451    #[test]
2452    fn p1_related_reads_as_one_association_across_orientations() {
2453        // `related` is the only non-directional kind, so both authorings are
2454        // the same association. Normalization collapses them to one canonical
2455        // triple and the read side shows one entry.
2456        let one = Owned::new(1, "A", "todo").with_related(2, "B");
2457        let two = Owned::new(2, "B", "todo").with_related(1, "A");
2458        let owned = vec![one, two];
2459        let graph = graph_of(&owned, "done");
2460        assert_eq!(graph.related_to(1), vec![2], "one association, not two");
2461        assert_eq!(graph.related_to(2), vec![1]);
2462        assert_eq!(
2463            normalize_all([(1, 2, RelationKind::Related), (2, 1, RelationKind::Related),]).len(),
2464            1,
2465            "both authorings normalize to one canonical edge"
2466        );
2467    }
2468
2469    // -- review round 3 regressions ---------------------------------------
2470
2471    #[test]
2472    fn a3_no_stored_relationship_id_silently_stops_resolving() {
2473        // Two stored spellings of one association. Both ids must keep working:
2474        // the file is human-editable, so the user still holds both, and a
2475        // dropped row would make one of them a bare `unknown_relation`.
2476        let one = Owned::new(1, "A", "todo").with_related(2, "B");
2477        let two = Owned::new(2, "B", "todo").with_related(1, "A");
2478        let owned = vec![one, two];
2479        let graph = graph_of(&owned, "done");
2480        let ids: Vec<String> = graph
2481            .all_relationships()
2482            .iter()
2483            .filter(|r| r.kind == RelationKind::Related)
2484            .map(|r| r.id.clone())
2485            .collect();
2486        assert_eq!(ids.len(), 2, "both stored rows are retained");
2487        for id in &ids {
2488            assert!(
2489                graph.find_relationship(id).is_some(),
2490                "stored id {id} must still resolve"
2491            );
2492            assert!(
2493                !graph
2494                    .validate_edit(id, None, None)
2495                    .has(DiagnosticKind::UnknownRelation),
2496                "editing by a stored id must not report it unknown"
2497            );
2498        }
2499    }
2500
2501    #[test]
2502    fn a3_the_blocked_adjacency_does_not_repeat_a_neighbour() {
2503        // Redundant rows are kept as rows, but the adjacency is a set.
2504        let mut one = Owned::new(1, "A", "todo");
2505        for _ in 0..3 {
2506            one.push_block(2, "B");
2507        }
2508        let owned = vec![one, Owned::new(2, "B", "todo")];
2509        let graph = graph_of(&owned, "done");
2510        assert_eq!(graph.blocks(1), &[2], "no repeated neighbour");
2511        assert_eq!(graph.blocked_by(2), &[1], "no repeated blocker");
2512        assert_eq!(
2513            graph.blocked_state(2).blockers.len(),
2514            1,
2515            "one blocker is reported once, not once per stored row"
2516        );
2517    }
2518
2519    #[test]
2520    fn c_an_unconfigured_done_column_fails_closed() {
2521        // With no `done_column`, no column means "finished", so a blocker with
2522        // an empty status must still block rather than read as completed.
2523        let mut one = Owned::new(1, "A", "");
2524        one.push_block(2, "B");
2525        let owned = vec![one, Owned::new(2, "B", "todo")];
2526        let graph = graph_of(&owned, "");
2527        assert!(
2528            graph.blocked_state(2).is_blocked,
2529            "an unconfigured completion column must not mark anything done"
2530        );
2531    }
2532
2533    #[test]
2534    fn p1_duplicates_orientation_is_preserved() {
2535        // `duplicates` is directional: these are two distinct claims.
2536        assert_eq!(
2537            normalize_all([
2538                (1, 2, RelationKind::Duplicates),
2539                (2, 1, RelationKind::Duplicates),
2540            ])
2541            .len(),
2542            2
2543        );
2544    }
2545
2546    #[test]
2547    fn p2_an_edit_cannot_land_on_a_triple_another_edge_occupies() {
2548        let mut a = Owned::new(1, "A", "todo");
2549        a.push_block(2, "B");
2550        a.relationships
2551            .push(Relationship::new(1, 2, RelationKind::Related, "B"));
2552        let owned = vec![a, Owned::new(2, "B", "todo")];
2553        let graph = graph_of(&owned, "done");
2554        let blocks_id = graph
2555            .relationships_for(1)
2556            .iter()
2557            .find(|r| r.kind == RelationKind::Blocks)
2558            .expect("a blocks edge")
2559            .id
2560            .clone();
2561
2562        let res = graph.validate_edit(&blocks_id, None, Some(RelationKind::Related));
2563        assert!(
2564            res.has(DiagnosticKind::ConflictingEdge),
2565            "re-kinding onto an occupied triple must be rejected: {res:?}"
2566        );
2567        assert!(
2568            res.errors
2569                .iter()
2570                .any(|d| d.message.contains("would store a second one")),
2571            "the message must name the duplicate case: {res:?}"
2572        );
2573    }
2574
2575    #[test]
2576    fn p2_creating_an_edge_that_already_exists_stays_idempotent() {
2577        // The create path must NOT reject a re-assertion: `tix link` run twice
2578        // has to be a no-op, not a failure (data-model §2).
2579        let a = Owned::new(1, "A", "todo").blocking(2, "B");
2580        let owned = vec![a, Owned::new(2, "B", "todo")];
2581        let graph = graph_of(&owned, "done");
2582        assert!(
2583            graph.validate_create(1, 2, RelationKind::Blocks).is_clean(),
2584            "re-asserting an existing edge is the idempotent path"
2585        );
2586    }
2587
2588    #[test]
2589    fn p3_p4_the_other_endpoint_is_returned_in_both_orientations() {
2590        // Edges authored from the far side, which is the case round 1 missed.
2591        let mut two = Owned::new(2, "B", "todo");
2592        two.relationships
2593            .push(Relationship::new(2, 1, RelationKind::Related, "A"));
2594        two.relationships
2595            .push(Relationship::new(2, 1, RelationKind::Duplicates, "A"));
2596        let owned = vec![Owned::new(1, "A", "todo"), two];
2597        let graph = graph_of(&owned, "done");
2598
2599        // `related` is non-directional: both endpoints see each other.
2600        assert_eq!(graph.related_to(1), vec![2], "never the subject itself");
2601        assert_eq!(graph.related_to(2), vec![1]);
2602        // `duplicates` is directional, so a far-side edge contributes nothing.
2603        assert!(
2604            graph.duplicates_of(1).is_empty(),
2605            "a directional edge pointing at #1 does not mean #1 duplicates it: {:?}",
2606            graph.duplicates_of(1)
2607        );
2608        assert_eq!(graph.duplicates_of(2), vec![1]);
2609    }
2610
2611    #[test]
2612    fn p5_the_blocked_adjacency_never_contains_the_subject() {
2613        // This is the invariant that makes the `blocker != subject` branch in
2614        // the resolution predicate unreachable. If a future change breaks it,
2615        // this fails rather than the branch silently carrying the load.
2616        let mut a = Owned::new(1, "A", "todo");
2617        a.relationships
2618            .push(Relationship::new(1, 1, RelationKind::Blocks, "A"));
2619        a.push_block(2, "B");
2620        let owned = vec![a, Owned::new(2, "B", "todo")];
2621        let graph = graph_of(&owned, "done");
2622        for number in [1, 2] {
2623            assert!(
2624                !graph.blocked_by(number).contains(&number),
2625                "issue #{number} must never appear in its own inbound blocks adjacency"
2626            );
2627        }
2628    }
2629
2630    // -- review round 1 regressions ---------------------------------------
2631
2632    #[test]
2633    fn r1_display_is_refreshed_when_the_target_is_renamed() {
2634        // The edge was created while #2 was titled "Old title".
2635        let a = Owned::new(1, "A", "todo").blocking(2, "Old title");
2636        let owned = vec![a, Owned::new(2, "New title", "todo")];
2637        let graph = graph_of(&owned, "done");
2638        assert_eq!(
2639            graph.relationships_for(1)[0].display,
2640            "blocks #2 New title",
2641            "a persisted display must not outlive the title it was built from"
2642        );
2643    }
2644
2645    #[test]
2646    fn r1_a_dangling_edge_keeps_its_persisted_display() {
2647        // No target, so there is no title to rebuild the label from.
2648        let mut a = Owned::new(1, "A", "todo");
2649        a.relationships
2650            .push(Relationship::new(1, 404, RelationKind::Blocks, "gone"));
2651        let owned = vec![a];
2652        assert_eq!(
2653            graph_of(&owned, "done").relationships_for(1)[0].display,
2654            "blocks #404 gone"
2655        );
2656    }
2657
2658    #[test]
2659    fn r2_a_self_link_is_reported_once_not_twice() {
2660        let mut a = Owned::new(1, "A", "todo");
2661        a.relationships
2662            .push(Relationship::new(1, 1, RelationKind::Blocks, "A"));
2663        let owned = vec![a];
2664        let graph = graph_of(&owned, "done");
2665        assert_eq!(graph.edge_count(), 1);
2666        assert_eq!(
2667            graph.relationships_for(1).len(),
2668            1,
2669            "a self-link has one endpoint, so it must be registered once"
2670        );
2671    }
2672
2673    #[test]
2674    fn r3_an_issue_is_never_blocked_by_itself() {
2675        let mut a = Owned::new(1, "A", "todo");
2676        a.relationships
2677            .push(Relationship::new(1, 1, RelationKind::Blocks, "A"));
2678        let owned = vec![a];
2679        let graph = graph_of(&owned, "done");
2680
2681        // Still visible as invalid data...
2682        assert!(graph.validate_snapshot().has(DiagnosticKind::SelfLink));
2683        // ...but never as a real edge, so it cannot make #1 its own blocker.
2684        assert!(graph.blocks(1).is_empty());
2685        let state = graph.blocked_state(1);
2686        assert!(!state.is_blocked, "an issue must not block itself");
2687        assert!(state.reason.is_empty());
2688    }
2689
2690    #[test]
2691    fn r4_validate_edit_does_not_hide_a_pre_existing_conflict() {
2692        // #1 -> #2 blocks, plus #1 -> #2 related: the data is already corrupt.
2693        let mut a = Owned::new(1, "A", "todo");
2694        a.push_block(2, "B");
2695        a.relationships
2696            .push(Relationship::new(1, 2, RelationKind::Related, "B"));
2697        let owned = vec![a, Owned::new(2, "B", "todo")];
2698        let graph = graph_of(&owned, "done");
2699        let blocks_id = graph
2700            .relationships_for(1)
2701            .iter()
2702            .find(|r| r.kind == RelationKind::Blocks)
2703            .expect("a blocks edge")
2704            .id
2705            .clone();
2706
2707        assert!(
2708            graph
2709                .validate_snapshot()
2710                .has(DiagnosticKind::ConflictingEdge)
2711        );
2712        let edit = graph.validate_edit(&blocks_id, None, None);
2713        assert!(
2714            edit.has(DiagnosticKind::ConflictingEdge),
2715            "editing an unrelated field must not return a clean verdict on corrupt data: {edit:?}"
2716        );
2717    }
2718
2719    #[test]
2720    fn r4_an_edit_does_not_conflict_with_itself() {
2721        let a = Owned::new(1, "A", "todo").blocking(2, "B");
2722        let owned = vec![a, Owned::new(2, "B", "todo"), Owned::new(3, "C", "todo")];
2723        let graph = graph_of(&owned, "done");
2724        let id = graph.relationships_for(1)[0].id.clone();
2725
2726        assert!(
2727            graph.validate_edit(&id, None, None).is_clean(),
2728            "an edge never conflicts with itself"
2729        );
2730        assert!(
2731            graph.validate_edit(&id, Some(3), None).is_clean(),
2732            "retargeting to an unrelated issue is not a conflict"
2733        );
2734    }
2735
2736    // -- diagnostics --------------------------------------------------------
2737
2738    #[test]
2739    fn every_contract_diagnostic_kind_exists_with_its_exact_name() {
2740        let names: Vec<&str> = [
2741            DiagnosticKind::SelfLink,
2742            DiagnosticKind::MissingTarget,
2743            DiagnosticKind::ConflictingEdge,
2744            DiagnosticKind::Cycle,
2745            DiagnosticKind::UnknownKind,
2746            DiagnosticKind::UnknownRelation,
2747            DiagnosticKind::RetiredLinksKey,
2748        ]
2749        .iter()
2750        .map(|k| k.as_str())
2751        .collect();
2752        assert_eq!(
2753            names,
2754            [
2755                "self_link",
2756                "missing_target",
2757                "conflicting_edge",
2758                "cycle",
2759                "unknown_kind",
2760                "unknown_relation",
2761                "retired_links_key"
2762            ]
2763        );
2764    }
2765
2766    #[test]
2767    fn validate_snapshot_never_returns_an_err() {
2768        // The signature itself is the assertion: there is no Result to unwrap.
2769        let owned = vec![Owned::new(1, "A", "todo").with_retired_links_key()];
2770        let result: RelationshipValidationResult = graph_of(&owned, "done").validate_snapshot();
2771        assert!(result.blocking());
2772    }
2773
2774    #[test]
2775    fn diagnostics_sort_deterministically() {
2776        let mut a = RelationshipValidationResult::with(Diagnostic::self_link(1));
2777        a.merge(RelationshipValidationResult::with(
2778            Diagnostic::unknown_relation("rel_x"),
2779        ));
2780        let mut b = a.clone();
2781        b.sort_diagnostics();
2782        a.sort_diagnostics();
2783        assert_eq!(a, b);
2784    }
2785
2786    #[test]
2787    fn blocked_reason_is_shared_by_every_surface() {
2788        let single = vec![ChainNode::new(1, "A")];
2789        assert_eq!(blocked_reason(&single), "Blocked by #1 A");
2790        let many = vec![ChainNode::new(1, "A"), ChainNode::new(2, "B")];
2791        assert_eq!(blocked_reason(&many), "Blocked by 2 issues: #1 A, #2 B");
2792    }
2793
2794    #[test]
2795    fn cycle_paths_are_reported_once_regardless_of_starting_node() {
2796        let a = Owned::new(1, "A", "todo").blocking(2, "B");
2797        let b = Owned::new(2, "B", "todo").blocking(3, "C");
2798        let c = Owned::new(3, "C", "todo").blocking(1, "A");
2799        let owned = vec![a, b, c];
2800        let result = graph_of(&owned, "done").validate_snapshot();
2801        let cycles: Vec<&Diagnostic> = result
2802            .errors
2803            .iter()
2804            .filter(|d| d.kind == DiagnosticKind::Cycle)
2805            .collect();
2806        assert_eq!(
2807            cycles.len(),
2808            1,
2809            "one distinct cycle, one diagnostic: {result:?}"
2810        );
2811    }
2812
2813    #[test]
2814    fn hand_edited_blocked_by_persisted_edges_still_block() {
2815        // A hand-edited file that persisted the `blocked_by` spelling must not
2816        // silently drop out of blocked state. `from: 1, to: 2, blocked_by` is
2817        // "1 is blocked by 2", so it normalizes to 2 blocking 1.
2818        let mut owner = Owned::new(2, "B", "todo");
2819        owner.relationships.push(Relationship {
2820            id: "rel_hand".to_string(),
2821            from: 1,
2822            to: 2,
2823            kind: RelationKind::BlockedBy,
2824            display: String::new(),
2825        });
2826        let owned = vec![Owned::new(1, "A", "todo"), owner];
2827        let graph = graph_of(&owned, "done");
2828        assert!(
2829            graph.blocked_state(1).is_blocked,
2830            "the normalized adjacency must still carry the edge"
2831        );
2832        assert_eq!(graph.blocks(2), &[1], "stored in canonical blocks form");
2833    }
2834}