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