Skip to main content

tix/
storage.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use crate::assignee::derive_segment;
6use crate::config::Config;
7use crate::error::{Result, TixError, render_diagnostics};
8use crate::hierarchy::{self, HierarchyIndex};
9use crate::model::{HistoryEventKind, Issue, Status, TransitionDecision};
10use crate::relationships::{
11    CanonicalEdge, Diagnostic, IssueView, RelationKind, Relationship, RelationshipGraph,
12};
13use crate::slug::slugify;
14
15pub struct Store {
16    pub issues_dir: PathBuf,
17    pub config: Config,
18}
19
20/// The outcome of one relationship mutation.
21///
22/// A typed relationship is a **single directed edge**, so a mutation normally
23/// writes exactly one issue file — the one whose frontmatter owns the canonical
24/// edge. `written` is `None` when the mutation was a no-op (an
25/// already-present edge, or an absent edge on remove), which is what makes
26/// "an idempotent re-apply appends no second History event" checkable.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct RelationshipOutcome {
29    /// The issue whose file was written, if any.
30    pub written: Option<Issue>,
31    /// The relationship the mutation concerned, in canonical form.
32    pub relationship: Relationship,
33    /// Whether a file was actually written.
34    pub changed: bool,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum IssueSelector {
39    Number(u32),
40    Filename(String),
41    Slug(String),
42}
43
44/// Result of `Store::add_asset`: the relative markdown link inserted into the
45/// body and the actual written file name (collision-resolved).
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct AssetAdded {
48    pub link: String,
49    pub written_name: String,
50}
51
52// Disclosures raised by writes, drained by whichever surface is presenting.
53//
54// A disclosure is something the user must be told that is not an error: today
55// that is only the removal of the retired `links:` key. It is queued rather than
56// printed so every surface can render it in its own channel — stderr for the
57// CLI, the MCP result payload for a tool call, the status line for the TUI.
58//
59// Thread-local rather than process-global: a CLI and an MCP request each drain
60// their own, and a process-global queue made concurrent callers — and the test
61// suite, which runs in parallel — see each other's disclosures.
62thread_local! {
63    static RETIRED_KEY_DISCLOSURES: std::cell::RefCell<Vec<String>> =
64        const { std::cell::RefCell::new(Vec::new()) };
65}
66
67/// Record that a write removed the retired `links:` key from `filename`.
68/// `pub(crate)` so the surface drains in `app` and `mcp` can be tested against
69/// a raised disclosure. Production code reaches it only through `Store::update`.
70pub(crate) fn retired_key_disclosure(filename: &str) {
71    RETIRED_KEY_DISCLOSURES.with(|queue| {
72        queue.borrow_mut().push(format!(
73            "remove `links:` by hand to silence \u{2014} {filename} (retired by \
74             C-001; never read as typed data, no migration)"
75        ));
76    });
77}
78
79/// Take everything queued since the last drain.
80pub fn take_disclosures() -> Vec<String> {
81    RETIRED_KEY_DISCLOSURES.with(|queue| std::mem::take(&mut *queue.borrow_mut()))
82}
83
84/// The identity recorded as the `actor` of a History event.
85///
86/// Falls back to `unknown` rather than failing a mutation, so a repository with
87/// no configured git identity can still record an audit trail.
88///
89/// Public because the blocked-move guard (WP04) records its own History event
90/// outside the storage layer — the acknowledgement is a *policy* decision, not a
91/// storage step. Duplicating the git-identity resolution in the command layer
92/// would give the audit trail two definitions of "who acted", which is the kind
93/// of drift an audit trail exists to prevent.
94pub fn actor_identity() -> String {
95    crate::assignee::git_identity()
96        .map(|id| id.to_string())
97        .unwrap_or_else(|| "unknown".to_string())
98}
99
100/// A request to move an issue to a column, through the seam.
101///
102/// WP03 T013. A struct rather than a positional list because the three arguments
103/// are all `Option`-shaped and adjacent: `(Some(commit), None, None)` is a mistake
104/// waiting to happen at a call site, and the field names are what stop it.
105///
106/// ## Deliberately absent
107///
108/// **No `actor`.** The seam records the identity itself through
109/// [`actor_identity`], so a caller cannot attribute a decision to somebody else
110/// and the audit trail keeps one definition of "who acted". Passing an actor in
111/// would make the attribution a choice of the caller, which is exactly the thing
112/// an audit trail must not be.
113///
114/// **No `updated`.** The seam bumps it, on exactly the transitions that change
115/// something. A caller supplying it would be able to write a file whose date says
116/// a no-op was an edit.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct StatusChange<'a> {
119    /// The column to move to. A column the board does not configure is refused
120    /// with `unknown_column`; a same-column request is accepted and inert.
121    pub column: &'a str,
122    /// The commit to record when this move reaches the board's completion column.
123    ///
124    /// Ignored on a move that does not reach completion, and a previously recorded
125    /// commit is cleared on a move that *leaves* it. `None` therefore means "record
126    /// nothing", which is distinct from "leave the old value alone" — leaving an
127    /// issue in its completion column with an unrelated commit is what the clear
128    /// exists to prevent.
129    pub commit: Option<&'a str>,
130    /// Why a policy rule was being overridden, when one was.
131    ///
132    /// Without a reason the service refuses a transition out of a terminal
133    /// category outright, so this is the only way out of `done`. The CLI flag that
134    /// supplies it lands in WP04; the seam takes the parameter now so a
135    /// half-built seam — one that can refuse but never accept-with-reason — is
136    /// never what ships.
137    pub override_reason: Option<&'a str>,
138}
139
140impl<'a> StatusChange<'a> {
141    /// A plain move: no commit, no override.
142    ///
143    /// The common case, named so the call site reads as "move it" rather than as
144    /// three `None`s.
145    pub fn to(column: &'a str) -> Self {
146        Self {
147            column,
148            commit: None,
149            override_reason: None,
150        }
151    }
152
153    /// This request with a commit, for a move that reaches completion.
154    pub fn with_commit(mut self, commit: Option<&'a str>) -> Self {
155        self.commit = commit;
156        self
157    }
158
159    /// This request with an override reason, for a move out of a terminal
160    /// category.
161    pub fn with_override(mut self, reason: Option<&'a str>) -> Self {
162        self.override_reason = reason;
163        self
164    }
165}
166
167/// What the seam did (`WP03` T013).
168///
169/// `written` and `decision` are deliberately redundant. Either alone would do:
170/// `written == false` implies `decision.is_none()`, and a decision always implies
171/// a write. They are both here because the two questions a caller asks are
172/// different — "did anything happen?" and "what gets appended?" — and reading the
173/// answer to one off the other is the kind of inference that breaks silently when a
174/// fourth outcome is added later.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct StatusOutcome {
177    /// The issue as it now stands in the store: the accepted column, the bumped
178    /// `updated`, and the appended decision. On an inert no-op this is the issue
179    /// exactly as it was.
180    pub issue: Issue,
181    /// The decision appended to the issue's History, or `None` when nothing was
182    /// written.
183    pub decision: Option<TransitionDecision>,
184    /// Whether the file was written at all.
185    ///
186    /// `false` for a same-column re-apply and for any path that carries no change.
187    /// A refusal is not represented here — it is an `Err`, so "refused" and
188    /// "accepted but did nothing" are different types rather than one `Outcome`
189    /// with a flag.
190    pub written: bool,
191}
192
193/// The accepted half of [`crate::workflow::TransitionOutcome`], unpacked.
194///
195/// WP03. A private struct so the seam's body reads as ordinary code, and so the
196/// only way to reach the write is to have destructured the service's `Accepted`
197/// variant. The alternative — matching on the enum at each use — is how a future
198/// edit ends up handling a new outcome variant once in this function and not at
199/// all in the next one.
200struct TransitionAccepted {
201    changed: bool,
202    from: String,
203    to: String,
204    from_category: crate::workflow::WorkflowCategory,
205    to_category: crate::workflow::WorkflowCategory,
206    override_reason: Option<String>,
207}
208
209impl TransitionAccepted {
210    /// The accepted transition, or the diagnostic that refused it.
211    fn try_from(
212        outcome: crate::workflow::TransitionOutcome,
213    ) -> std::result::Result<Self, crate::workflow::WorkflowDiagnostic> {
214        match outcome {
215            crate::workflow::TransitionOutcome::Accepted {
216                changed,
217                from,
218                to,
219                from_category,
220                to_category,
221                override_reason,
222            } => Ok(Self {
223                changed,
224                from,
225                to,
226                from_category,
227                to_category,
228                override_reason,
229            }),
230            crate::workflow::TransitionOutcome::Rejected(diagnostic) => Err(diagnostic),
231        }
232    }
233}
234
235/// A [`Relationship`] standing for a canonical edge that has no stored row.
236///
237/// Deliberately local to the storage layer rather than a method on
238/// `CanonicalEdge`: the service's surface is owned by WP01 and is not widened
239/// from here. The id and display are empty because no row exists to carry them
240/// — this value only reports *which* edge a no-op concerned.
241fn canonical_placeholder(canonical: &CanonicalEdge) -> Relationship {
242    Relationship {
243        id: String::new(),
244        from: canonical.from,
245        to: canonical.to,
246        kind: canonical.kind,
247        display: String::new(),
248    }
249}
250
251/// Turn a blocking validation result into the crate error surface.
252///
253/// The first diagnostic's message is preserved verbatim so the user sees the
254/// field-aware text the service produced, prefixed with its diagnostic name
255/// (C-004). A blocking result means **no** file was written (SC-005), so this
256/// is raised before any mutation.
257fn relationship_error(errors: &[Diagnostic]) -> TixError {
258    if errors.is_empty() {
259        return TixError::InvalidInput("relationship rejected".into());
260    }
261    TixError::InvalidInput(render_diagnostics(
262        errors.iter().map(|d| (d.kind.as_str(), d.message.as_str())),
263    ))
264}
265
266/// A durable, exclusive claim on one issue number.
267///
268/// ## Why this exists (#1352, part 2)
269///
270/// [`Store::resolve_next_number`] reads the directory, takes `max(number)`, and adds
271/// one. That is a **hint**, not an allocation: nothing serialises read-then-write
272/// across processes, so two `tix create` runs that reach it together both get the
273/// same answer and both write an issue with that number.
274///
275/// Measured with 24 concurrent `tix create` subprocesses with distinct titles:
276///
277/// ```text
278/// files: 24   distinct numbers: 8
279///   5 × number: 7    4 × number: 4    4 × number: 6    3 × number: 3
280///   3 × number: 5    2 × number: 0    2 × number: 1    1 × number: 2
281/// ```
282///
283/// 16 of 24 issues shared a number with another file. After that every
284/// number-addressed operation — `tix view 0`, `tix move 0`, `tix edit 0`,
285/// `tix link 0 X`, `tix delete 0` — silently addresses an arbitrary one of several
286/// issues, which is arguably worse than the clobber it accompanies.
287///
288/// It needs **two** processes (an agent's `tix mcp` plus a human's `tix`), which is
289/// why it is rarer and why spacing the calls out appears to fix it. There is no
290/// intra-process race: `src/mcp/mod.rs` handles one message to completion before
291/// reading the next, the TUI is a single event loop, and `Store` is `&self`.
292///
293/// ## Why a claim ledger and not a lockfile
294///
295/// The obvious fix is a lock file held across resolve-and-write, and it was rejected
296/// because **a lock can be lost**: a process that crashes while holding it leaves the
297/// next create to break the lock on a timeout, and breaking a lock on a timeout also
298/// breaks it for a process that is merely slow. Every lock-staleness heuristic trades
299/// mutual exclusion for liveness, and this is the one property that must not be
300/// traded.
301///
302/// A claim ledger cannot lose mutual exclusion. The claim is a zero-byte file created
303/// with `O_EXCL`, and a process that dies holding one leaves an **orphan claim** —
304/// an allocated-but-unused number, never a lost lock. A burned number is a gap, and
305/// this system already produces gaps deliberately: reusing a number requires deleting
306/// every higher-numbered issue first, and deleting an issue leaves its number free.
307/// So the failure mode of the fix matches the existing, documented behaviour of the
308/// allocator rather than introducing a new one.
309///
310/// The cost is one hidden marker file per created issue. That is real, and it is the
311/// price of not needing a lease timeout. The markers live **beside** the issues
312/// directory rather than inside it, so nothing that reads `docs/issues/` — a glob, a
313/// script, a test counting segments — ever sees them; see [`claims_dir`].
314struct NumberClaim {
315    path: PathBuf,
316    /// Whether this claim must be released on drop. `false` once the issue file
317    /// exists, at which point the file — not the marker — is the authority for the
318    /// number.
319    armed: bool,
320}
321
322impl NumberClaim {
323    /// The directory holding one marker per claimed number.
324    const DIR: &'static str = ".tix-numbers";
325
326    /// Claim `start`, or the next free number at or above it.
327    ///
328    /// `start` comes from [`Store::resolve_next_number`] and is already past every
329    /// number any issue file claims, so the first candidate is normally free and the
330    /// loop runs once. The loop exists for the concurrent case, and the climb is
331    /// bounded so a corrupt ledger produces an error rather than an unbounded scan.
332    fn acquire(issues_dir: &Path, start: u32) -> Result<Self> {
333        const MAX_ATTEMPTS: u32 = 10_000;
334        let dir = claims_dir(issues_dir);
335        std::fs::create_dir_all(&dir)?;
336        let mut number = start;
337        for _ in 0..MAX_ATTEMPTS {
338            let path = dir.join(format!("{number}.claim"));
339            match std::fs::OpenOptions::new()
340                .write(true)
341                .create_new(true)
342                .open(&path)
343            {
344                Ok(_) => {
345                    return Ok(Self { path, armed: true });
346                }
347                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
348                    number += 1;
349                }
350                Err(e) => return Err(TixError::Io(e)),
351            }
352        }
353        Err(TixError::Io(std::io::Error::new(
354            std::io::ErrorKind::AlreadyExists,
355            format!(
356                "no free issue number at or above {start} after {MAX_ATTEMPTS} attempts; \
357                 {} is probably corrupt",
358                dir.display()
359            ),
360        )))
361    }
362
363    /// The number this claim holds.
364    fn number(&self) -> Option<u32> {
365        self.path.file_stem()?.to_str()?.parse::<u32>().ok()
366    }
367
368    /// Free a number that is no longer in use.
369    ///
370    /// Called by [`Store::delete`], so the ledger does not permanently reserve the
371    /// number of a deleted issue. Best-effort: if the ledger directory is missing or
372    /// the removal fails, the create path simply climbs past the stale claim — which
373    /// costs a number but never hands the same number to two issues.
374    fn release_number(issues_dir: &Path, number: u32) {
375        let _ = std::fs::remove_file(claims_dir(issues_dir).join(format!("{number}.claim")));
376    }
377
378    /// Give the number back, so a create that failed after claiming it does not burn
379    /// one. Idempotent, and a no-op for an already-committed claim.
380    fn release(&mut self) {
381        if self.armed {
382            let _ = std::fs::remove_file(&self.path);
383            self.armed = false;
384        }
385    }
386
387    /// Keep the claim permanently, because the issue file for it now exists.
388    ///
389    /// Without this, `Drop` would release the marker and the whole mechanism would
390    /// be a no-op — which is exactly what happened the first time this was written.
391    /// The `Drop` backstop below is for frames that *never reach* a commit, so it
392    /// must be disarmed deliberately on the success path rather than left to run
393    /// unconditionally.
394    fn commit(&mut self) {
395        self.armed = false;
396    }
397}
398
399impl Drop for NumberClaim {
400    /// A claim still armed when the `Store::create` frame unwinds — a `write`
401    /// error, or a panic — is released, so a failed create does not leak a number.
402    /// The `is_err` branch in `create` releases on the ordinary path; this is the
403    /// backstop for the paths that never reach it.
404    fn drop(&mut self) {
405        self.release();
406    }
407}
408
409impl std::fmt::Debug for NumberClaim {
410    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411        f.debug_struct("NumberClaim")
412            .field("path", &self.path)
413            .field("armed", &self.armed)
414            .finish()
415    }
416}
417
418impl PartialEq for NumberClaim {
419    fn eq(&self, other: &Self) -> bool {
420        self.path == other.path
421    }
422}
423impl Eq for NumberClaim {}
424
425/// The outcome of a hierarchy write, mirroring [`RelationshipOutcome`] so a
426/// surface handles both with the same shape.
427#[derive(Debug, Clone)]
428pub struct ParentOutcome {
429    /// The child as written, or `None` when the mutation was a no-op.
430    ///
431    /// `None` for a no-op, matching [`RelationshipOutcome`]: a retried command must
432    /// not pad the audit trail, and a `changed: true` that wrote nothing is a lie.
433    /// A caller that needs the current state reads [`Self::parent`], which is
434    /// reported either way — the same split `RelationshipOutcome` uses for
435    /// `relationship`.
436    pub written: Option<Issue>,
437    /// The child's parent after the call.
438    pub parent: Option<u32>,
439    /// The event appended to `## History`, or `None` for a no-op.
440    ///
441    /// `None` for a no-op is the point: a retried command must not pad the
442    /// audit trail, and a `changed: true` that wrote nothing is a lie.
443    pub event: Option<crate::hierarchy::ParentEvent>,
444    /// Whether anything was written.
445    pub changed: bool,
446}
447
448impl Store {
449    pub fn open(config: &Config) -> Result<Store> {
450        let issues_dir = &config.issues_dir;
451        if !issues_dir.exists() {
452            std::fs::create_dir_all(issues_dir)?;
453        }
454        Ok(Store {
455            issues_dir: issues_dir.clone(),
456            config: config.clone(),
457        })
458    }
459
460    /// The **active** issue set: every issue file that is not archived.
461    ///
462    /// Archived issues are marked by a dot-prefixed markdown filename
463    /// (`docs/issues/<assignee>/.<file>.md`, issue #1492). They are skipped
464    /// **from the directory entry**, before the file is opened, so the default
465    /// path never parses an archived issue — the performance requirement that
466    /// keeps a repository with thousands of archived issues fast.
467    ///
468    /// Use [`Store::list_all`] when an archived issue must still be reachable.
469    pub fn list(&self) -> Result<Vec<Issue>> {
470        self.scan(false)
471    }
472
473    /// The **exhaustive** issue set: active issues plus archived ones.
474    ///
475    /// Archived issues carry `archived == true`, derived from the leading dot
476    /// of their filename. This is the enumeration for selector resolution and
477    /// number allocation, both of which must see an archived issue; it is
478    /// deliberately *not* the default view.
479    pub fn list_all(&self) -> Result<Vec<Issue>> {
480        self.scan(true)
481    }
482
483    /// Walk the issues tree.
484    ///
485    /// `include_archived == false` is the default path: a dot-prefixed `.md`
486    /// entry is dropped from the directory entry so the file is never read.
487    /// `true` reads them too and marks them with `archived = true`.
488    fn scan(&self, include_archived: bool) -> Result<Vec<Issue>> {
489        let mut issues: Vec<Issue> = Vec::new();
490        if !self.issues_dir.exists() {
491            return Ok(issues);
492        }
493        for entry in std::fs::read_dir(&self.issues_dir)? {
494            let entry = entry?;
495            let path = entry.path();
496            let name = entry.file_name().to_string_lossy().to_string();
497            if path.is_dir() {
498                // One level of assignee segments. Temp/backup marker dirs are
499                // never treated as segments (their .md children are clutter);
500                // neither are `<slug>-assets/` dirs (FR-006) — they hold
501                // binary assets, never issues.
502                if name.starts_with(".tix-tmp-") || name.ends_with("-assets") {
503                    continue;
504                }
505                for child in std::fs::read_dir(&path)? {
506                    let child = child?;
507                    let child_path = child.path();
508                    if !child_path.is_file() {
509                        continue;
510                    }
511                    let child_name = child.file_name().to_string_lossy().to_string();
512                    if !child_name.ends_with(".md") {
513                        continue;
514                    }
515                    // The archived marker lives on the **file name**, so this is
516                    // decided from the directory entry and the skip happens
517                    // before `read_to_string` below.
518                    let archived = child_name.starts_with('.');
519                    if archived && !include_archived {
520                        continue;
521                    }
522                    // Grouped issue: relative filename is `<segment>/<basename>`.
523                    let rel = format!("{name}/{child_name}");
524                    let contents = std::fs::read_to_string(&child_path)?;
525                    let mut issue = Issue::parse(&contents).map_err(|e| match e {
526                        TixError::Parse(msg) => TixError::Parse(format!("{rel}: {msg}")),
527                        other => other,
528                    })?;
529                    issue.filename = rel;
530                    issue.archived = archived;
531                    issues.push(issue);
532                }
533            } else if path.is_file() && name.ends_with(".md") {
534                // Legacy flat file directly under the issues dir (C-010).
535                let archived = name.starts_with('.');
536                if archived && !include_archived {
537                    continue;
538                }
539                let contents = std::fs::read_to_string(&path)?;
540                let mut issue = Issue::parse(&contents).map_err(|e| match e {
541                    TixError::Parse(msg) => TixError::Parse(format!("{name}: {msg}")),
542                    other => other,
543                })?;
544                issue.filename = name;
545                issue.archived = archived;
546                issues.push(issue);
547            }
548        }
549        issues.sort_by(|a, b| a.filename.cmp(&b.filename));
550        Ok(issues)
551    }
552
553    /// The assignee storage folders under the issues directory, sorted.
554    ///
555    /// These are the `docs/issues/<username>/` segments [`Self::scan`] walks —
556    /// the ground truth for who has issues. The reserved segments are excluded:
557    /// `unassigned` is the fallback for "no assignee" and is not a person, and a
558    /// `<slug>-assets/` or `.tix-tmp-*` directory is not a person either. Used by
559    /// the Triage mode's assignee picker (design D6); never creates a directory.
560    pub fn assignee_segments(&self) -> Result<Vec<String>> {
561        let mut segments = Vec::new();
562        if !self.issues_dir.exists() {
563            return Ok(segments);
564        }
565        for entry in std::fs::read_dir(&self.issues_dir)? {
566            let entry = entry?;
567            if !entry.path().is_dir() {
568                continue;
569            }
570            let name = entry.file_name().to_string_lossy().to_string();
571            if name == "unassigned" || name.starts_with(".tix-tmp-") || name.ends_with("-assets") {
572                continue;
573            }
574            segments.push(name);
575        }
576        segments.sort();
577        segments.dedup();
578        Ok(segments)
579    }
580
581    pub fn get(&self, selector: IssueSelector) -> Result<Issue> {
582        match selector {
583            IssueSelector::Number(num) => {
584                let issues = self.list_all()?;
585                issues
586                    .into_iter()
587                    .find(|i| i.number == num)
588                    .ok_or_else(|| TixError::NotFound(format!("issue #{num}")))
589            }
590            IssueSelector::Filename(name) => {
591                validate_filename(&name)?;
592                let path = self.issues_dir.join(&name);
593                if !path.is_file() {
594                    return Err(TixError::NotFound(format!("file {name}")));
595                }
596                let contents = std::fs::read_to_string(&path)?;
597                let mut issue = Issue::parse(&contents)?;
598                issue.filename = name;
599                Ok(issue)
600            }
601            IssueSelector::Slug(slug) => {
602                let issues = self.list_all()?;
603                issues
604                    .into_iter()
605                    .find(|i| slugify(&i.title) == slug)
606                    .ok_or_else(|| TixError::NotFound(format!("slug {slug}")))
607            }
608        }
609    }
610
611    /// Read one issue by its **store-relative** filename (issue #10
612    /// follow-up).
613    ///
614    /// The single-file counterpart of [`Store::scan`], for the incremental
615    /// corpus path: it sets `filename` and `archived` exactly as `scan` does
616    /// (the archived marker is the leading dot of the file name) and applies
617    /// the same parse-error context, so an incrementally updated corpus holds
618    /// byte-identical issues to a full rebuild. It deliberately does **not**
619    /// linear-scan [`IssueSelector`] — the caller already has the relative
620    /// path from the watcher.
621    ///
622    /// A path that is not a file is `NotFound`; the caller decides whether
623    /// that means "removed".
624    pub fn read_issue_at(&self, rel: &Path) -> Result<Issue> {
625        let filename = rel.to_string_lossy().to_string();
626        let path = self.issues_dir.join(rel);
627        if !path.is_file() {
628            return Err(TixError::NotFound(format!("file {filename}")));
629        }
630        let contents = std::fs::read_to_string(&path)?;
631        let mut issue = Issue::parse(&contents).map_err(|e| match e {
632            TixError::Parse(msg) => TixError::Parse(format!("{filename}: {msg}")),
633            other => other,
634        })?;
635        issue.filename = filename;
636        issue.archived = rel
637            .file_name()
638            .and_then(|name| name.to_str())
639            .is_some_and(|name| name.starts_with('.'));
640        Ok(issue)
641    }
642
643    /// Archive an issue: rename its file to a dot-prefixed name **in place**.
644    ///
645    /// The marker is the filename (issue #1492), so this is a rename within the
646    /// same assignee segment (`x.md` ↔ `.x.md`) plus exactly one `## History`
647    /// event. `number`, `status`, `created` and every other frontmatter field
648    /// are left untouched, no data is dropped, and the file is never deleted or
649    /// moved between directories.
650    ///
651    /// An already-archived issue is refused with the `already_archived`
652    /// diagnostic rather than re-archived: the operation is not idempotent by
653    /// design, because a second call would append a second audit entry for a
654    /// mutation that did not happen.
655    pub fn archive(&self, sel: &str) -> Result<Issue> {
656        self.set_archived(sel, true)
657    }
658
659    /// Restore an archived issue: the inverse of [`Store::archive`].
660    ///
661    /// An active issue is refused with the `not_archived` diagnostic.
662    pub fn restore(&self, sel: &str) -> Result<Issue> {
663        self.set_archived(sel, false)
664    }
665
666    fn set_archived(&self, sel: &str, archive: bool) -> Result<Issue> {
667        // `resolve_issue` → `parse_selector` → `list_all`, so an archived issue
668        // resolves here even though the default view hides it.
669        let mut issue = self.resolve_issue(sel)?;
670        if issue.archived == archive {
671            return Err(TixError::InvalidInput(format!(
672                "{}: issue #{} ({}) is already {}",
673                if archive {
674                    "already_archived"
675                } else {
676                    "not_archived"
677                },
678                issue.number,
679                issue.filename,
680                if archive { "archived" } else { "active" },
681            )));
682        }
683        let old_rel = issue.filename.clone();
684        let new_rel = flip_archived(&old_rel, archive).ok_or_else(|| {
685            TixError::InvalidInput(format!(
686                "cannot {} {old_rel}: its filename does not carry the archived marker in the \
687                 expected position",
688                if archive { "archive" } else { "restore" }
689            ))
690        })?;
691        let old_path = self.issues_dir.join(&old_rel);
692        let new_path = self.issues_dir.join(&new_rel);
693        if !old_path.is_file() {
694            return Err(TixError::NotFound(format!("file {old_rel}")));
695        }
696        if new_path.exists() {
697            return Err(TixError::InvalidInput(format!(
698                "cannot {} {old_rel}: {new_rel} already exists",
699                if archive { "archive" } else { "restore" }
700            )));
701        }
702        // Rename first: it is atomic, stays in the same directory, and cannot
703        // lose content. Only then is the audit event written, at the new name.
704        // A failure between the two leaves the file renamed but without the
705        // event — never a duplicate, never lost data.
706        std::fs::rename(&old_path, &new_path)?;
707        issue.filename = new_rel.clone();
708        issue.archived = archive;
709        issue.history.append_now(
710            &actor_identity(),
711            if archive {
712                HistoryEventKind::Archived
713            } else {
714                HistoryEventKind::Restored
715            },
716            &format!("{old_rel} -> {new_rel}"),
717        );
718        // No `bump_updated`: `updated` is a frontmatter field and archiving must
719        // leave every frontmatter field byte-identical.
720        self.update(&new_rel, &issue)?;
721        Ok(issue)
722    }
723
724    /// Create an issue file atomically and assign its number by scanning the
725    /// live issue set (issue #52): the highest existing `number` plus one,
726    /// with a floor of `0` for an empty store.
727    ///
728    /// The number is resolved at creation time rather than read from a
729    /// persisted `[state].next_number` counter, so `tix.toml` is never
730    /// rewritten by `create`. A deleted high-numbered issue's number can be
731    /// reused by a later create; the frontmatter `number` stays stable for
732    /// every surviving issue (C-007).
733    ///
734    /// The file is written into the assignee segment derived from
735    /// `issue.assignee` (`unassigned/` fallback, FR-029/FR-030), creating the
736    /// segment directory as needed, and `issue.filename` is set to the
737    /// relative path so `create` output and later edits agree. Atomic-write
738    /// discipline (temp + rename, NFR-006) is preserved.
739    pub fn create(
740        &self,
741        issue: &mut Issue,
742        body: &str,
743        filename_stem: Option<&str>,
744    ) -> Result<PathBuf> {
745        issue.body = body.to_string();
746        issue.bump_updated();
747
748        std::fs::create_dir_all(&self.issues_dir)?;
749        issue.number = self.resolve_next_number()?;
750
751        // Claim the number **before** anything is serialised, and hold it across the
752        // whole write. `resolve_next_number` above reads the directory, takes `max`,
753        // and adds 1, which is only a *hint*: two processes reaching this line
754        // together both get the same answer and both proceed. See [`NumberClaim`]
755        // for what that cost and why an `O_EXCL` marker is the fix.
756        //
757        // **Before** the serialisation, specifically. `to_file_contents` embeds
758        // `number:` in the frontmatter, so serialising first and claiming after —
759        // which is the order this was first written in — writes a *stale* number into
760        // the file while the ledger records the correct one. The 24-create test still
761        // saw 8 distinct numbers with 24 valid claims in place, because every claim
762        // was correct and every file was wrong. A claim that does not reach the bytes
763        // on disk is not a claim.
764        let mut claim = NumberClaim::acquire(&self.issues_dir, issue.number)?;
765        // Belt and braces (#1352): the number actually claimed must be the number
766        // about to be written. `acquire` only climbs when the requested number is
767        // already taken, so this is normally an identity — but it means a future
768        // change to either side that drifts from the other fails loudly here
769        // instead of writing an issue whose `number:` disagrees with the ledger.
770        issue.number = claim.number().ok_or_else(|| {
771            TixError::Io(std::io::Error::new(
772                std::io::ErrorKind::InvalidData,
773                format!("claimed number is unreadable from {}", claim.path.display()),
774            ))
775        })?;
776
777        // WP02 layout: files live under `<assignee-segment>/` with `unassigned/`
778        // as the fallback (FR-029, FR-030). The segment is generated by
779        // `derive_segment`, which is filesystem-safe by construction.
780        let segment = derive_segment(issue.assignee.as_deref());
781        std::fs::create_dir_all(self.issues_dir.join(&segment))?;
782
783        let stem = match filename_stem {
784            Some(s) => s.to_string(),
785            None => issue.filename_stem(),
786        };
787        // Serialised **after** the number is fixed, so the `number:` in the file is
788        // the claimed one.
789        let contents = issue.to_file_contents();
790
791        let written = self.write_new_issue_file(&segment, &stem, contents.as_bytes());
792        // The claim is now permanent: the issue file exists, so that file — not the
793        // marker — is the authority for the number, and dropping the marker would
794        // reopen the window this exists to close. A create that *failed* releases
795        // its claim instead, so a failure does not leak a number for nothing.
796        match written {
797            Ok((filename, path)) => {
798                claim.commit();
799                issue.filename = filename;
800                Ok(path)
801            }
802            Err(e) => {
803                claim.release();
804                Err(e)
805            }
806        }
807    }
808
809    /// Write a **new** issue file, never overwriting an existing one.
810    ///
811    /// The stem is `{created}-{slugified title}`, so it is a function of the issue's
812    /// creation date, its title, and its assignee segment — three things routinely
813    /// identical across two issues. Two `tix create --title "Same title"` runs on
814    /// the same day therefore resolved to the **same path**, and `atomic_write`
815    /// renames over the target, so the second create silently destroyed the first.
816    /// No concurrency was required, and both commands reported success:
817    ///
818    /// ```text
819    /// $ tix create --title "Same title" --priority high
820    /// Created herbert-maschke/2026-09-28-same-title.md
821    /// $ tix create --title "Same title" --priority low
822    /// Created herbert-maschke/2026-09-28-same-title.md
823    /// $ ls docs/issues/*/
824    /// 2026-09-28-same-title.md            # one file
825    /// $ tix list
826    /// NUM  TITLE       STATUS  PRIORITY
827    /// 2    Same title  todo    low       # issue #1 is gone
828    /// ```
829    ///
830    /// Two layers, because the first alone is still a race:
831    ///
832    /// 1. **Probe** for a free name, appending `-2`, `-3`, … to the stem. This
833    ///    handles the deterministic case, which is the common one.
834    /// 2. **Create the file with `O_EXCL`** via [`write_file_new`]. Probing cannot
835    ///    see a file another process creates between the probe and the write, and
836    ///    `rename` overwrites unconditionally, so the final step is the one that
837    ///    actually guarantees no clobber. Losing the race retries from step 1.
838    ///
839    /// Probing alone is the version that looks correct and is not: a second process
840    /// that writes the same stem in the same millisecond would still be destroyed,
841    /// and the retry-on-`AlreadyExists` below exists precisely because a reviewer
842    /// should not have to take that on trust.
843    fn write_new_issue_file(
844        &self,
845        segment: &str,
846        stem: &str,
847        contents: &[u8],
848    ) -> Result<(String, PathBuf)> {
849        const MAX_ATTEMPTS: usize = 1_000;
850        let mut suffix = 1usize;
851        for _ in 0..MAX_ATTEMPTS {
852            let filename = if suffix == 1 {
853                format!("{segment}/{stem}.md")
854            } else {
855                format!("{segment}/{stem}-{suffix}.md")
856            };
857            let path = self.issues_dir.join(&filename);
858            match write_file_new(&self.issues_dir, &path, contents) {
859                Ok(()) => return Ok((filename, path)),
860                // Someone took this name between the probe and the write. The
861                // collision is benign — it is only a *name* — so take the next one.
862                Err(TixError::Io(e)) if e.kind() == std::io::ErrorKind::AlreadyExists => {
863                    suffix += 1;
864                }
865                Err(e) => return Err(e),
866            }
867        }
868        Err(TixError::Io(std::io::Error::new(
869            std::io::ErrorKind::AlreadyExists,
870            format!(
871                "could not find a free filename for {segment}/{stem}.md in {MAX_ATTEMPTS} attempts"
872            ),
873        )))
874    }
875
876    /// The number for the next created issue: `max(number)` over the live
877    /// issue set plus one, or `1` when no issues exist (issue #52 — no
878    /// persisted counter to advance). Scanning the same list that every
879    /// other command uses keeps number resolution conservative: reusing a
880    /// number requires first deleting every higher-numbered issue.
881    ///
882    /// **The empty case is `1`, not `0`.** It used to be `0`, because `max()` over
883    /// an empty set has no natural answer and `0` was the fallback — which made the
884    /// first issue in every fresh repository `#0`, and that number is then
885    /// indistinguishable from a sentinel, an uninitialised value, or a field that
886    /// failed to read. Found by running the binary rather than reading the code
887    /// (tix #1376); two places in the codebase had already written the opposite
888    /// assumption into a comment, and a third had used `0` as a
889    /// `HierarchyNode::NO_ISSUE_YET` marker.
890    ///
891    /// Only the empty case changes. With any issue present the answer is `max + 1`,
892    /// which is unaffected — so no existing repository renumbers, and no migration
893    /// is implied. (One real issue in this repository is numbered `0`, written
894    /// before this fix; it keeps its number, because renumbering is a migration and
895    /// the mission's doctrine makes those an escalation trigger.)
896    fn resolve_next_number(&self) -> Result<u32> {
897        // `list_all`, not `list`: an archived issue keeps its number forever.
898        // Reading only the active set would let a `create` reuse the number of
899        // the highest-numbered archived issue (issue #1492).
900        let issues = self.list_all()?;
901        match issues.iter().map(|i| i.number).max() {
902            None => Ok(1),
903            Some(max) => max
904                .checked_add(1)
905                .ok_or_else(|| TixError::InvalidInput("issue-number overflow".to_string())),
906        }
907    }
908
909    pub fn config_path(&self) -> Result<std::path::PathBuf> {
910        if let Some(root) = Config::project_root(&self.issues_dir) {
911            Ok(root.join("tix.toml"))
912        } else {
913            Err(TixError::Config(
914                "tix.toml not found in any parent directory of issues_dir".into(),
915            ))
916        }
917    }
918
919    pub fn update(&self, filename: &str, issue: &Issue) -> Result<()> {
920        validate_filename(filename)?;
921        let path = self.issues_dir.join(filename);
922        if !path.is_file() {
923            return Err(TixError::NotFound(format!("file {filename}")));
924        }
925        // The retired `links:` key is not in the model, so re-serializing drops
926        // it — and with it the `retired_links_key` finding, silently. The
927        // consequence is that `tix lint` reports a problem, the user runs an
928        // unrelated `tix edit`, and both the finding and the data vanish with no
929        // notice. That is the defect; C-001's no-migration intent is not.
930        //
931        // The write still proceeds, because refusing would make tix unusable
932        // across the repository: 1216 of 1290 issue files here carry `links:`.
933        // What changes is that the removal is *disclosed*, naming the field and
934        // the file, so nothing is lost quietly. Removing the key by hand first
935        // silences this.
936        if issue.has_retired_links_key {
937            // Recorded on the store, not printed here. `eprintln!` inside a
938            // library function is invisible to the MCP server and the TUI, so
939            // the same operation produced different observable output per
940            // surface, which `contracts/surface-parity.md` forbids.
941            //
942            // Drained at the three surface boundaries, not at each write path:
943            // `main` for every CLI command, the MCP `serve` loop per request,
944            // and the TUI event loop. Draining per command was tried and reached
945            // only `link` and `edit`, so every other write path — `mv`,
946            // `rollover`, planning, sync, the TUI, MCP — warned about nothing.
947            // That was worse than the `eprintln!` it replaced, which fired from
948            // inside `update` and so covered all of them. A count of write
949            // paths is deliberately not stated here: it drifts, and a figure a
950            // later reader trusts is worse than no figure. `tests/mcp.rs` pins
951            // the behaviour instead, for `main` and the MCP server.
952            retired_key_disclosure(filename);
953        }
954        let contents = issue.to_file_contents();
955        atomic_write(&self.issues_dir, &path, contents.as_bytes())
956    }
957
958    /// Copy an asset into the issue's `<slug>-assets/` directory (FR-006) and
959    /// insert a relative markdown link at the end of the body, persisted via
960    /// [`Store::update`] (NFR-004).
961    ///
962    /// The asset dir is derived from the issue filename per
963    /// `contracts/comments-format.md`: `<slug>-assets/` where `<slug>` is the
964    /// basename stem minus the leading `YYYY-MM-DD-` date prefix. The copy is
965    /// atomic (temp + rename, NFR-004); on a name collision the file is stored
966    /// as `<stem>-<n>.<ext>` (first free `n`) and the inserted link points at
967    /// the actual written name. Image extensions (`.png` `.jpg` `.jpeg`
968    /// `.gif` `.svg` `.webp` `.bmp`) get the `![…](…)` form, everything else
969    /// the `[…](…)` form. The link path is relative to the issue file, so the
970    /// segment prefix is never part of it.
971    pub fn add_asset(&self, filename: &str, source_path: &Path) -> Result<AssetAdded> {
972        validate_filename(filename)?;
973        let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
974
975        let (segment, basename) = split_relative(filename).ok_or_else(|| {
976            TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
977        })?;
978        let stem = basename
979            .strip_suffix(".md")
980            .ok_or_else(|| {
981                TixError::InvalidInput(format!(
982                    "asset target must be a `.md` issue file: {filename}"
983                ))
984            })?
985            .to_string();
986        let slug = slug_from_stem(&stem);
987        let dir_name = format!("{slug}-assets");
988        let asset_dir = self.issues_dir.join(&segment).join(&dir_name);
989        std::fs::create_dir_all(&asset_dir)?;
990
991        let source_name = source_path
992            .file_name()
993            .and_then(|n| n.to_str())
994            .ok_or_else(|| {
995                TixError::InvalidInput("asset source must have a file name".to_string())
996            })?
997            .to_string();
998        let written_name = resolve_asset_name(&asset_dir, &source_name);
999        atomic_copy(&asset_dir, &asset_dir.join(&written_name), source_path)?;
1000
1001        let label = written_name
1002            .rsplit_once('.')
1003            .map(|(stem, _)| stem)
1004            .unwrap_or(&written_name);
1005        let link = if is_image_name(&written_name) {
1006            format!("![{label}]({dir_name}/{written_name})")
1007        } else {
1008            format!("[{label}]({dir_name}/{written_name})")
1009        };
1010
1011        // Append the link as a new line at the end of the body, reusing the
1012        // comment-appender newline discipline so the description is never
1013        // rewritten (FR-007).
1014        if !issue.body.is_empty() {
1015            if !issue.body.ends_with('\n') {
1016                issue.body.push('\n');
1017            }
1018            if !issue.body.ends_with("\n\n") {
1019                issue.body.push('\n');
1020            }
1021        }
1022        issue.body.push_str(&link);
1023        issue.body.push('\n');
1024        issue.bump_updated();
1025        self.update(filename, &issue)?;
1026
1027        Ok(AssetAdded { link, written_name })
1028    }
1029
1030    pub fn delete(&self, filename: &str) -> Result<()> {
1031        validate_filename(filename)?;
1032        let path = self.issues_dir.join(filename);
1033        if !path.is_file() {
1034            return Err(TixError::NotFound(format!("file {filename}")));
1035        }
1036        // Read the number **before** the file goes, so the claim ledger can be told
1037        // the number is free again (#1352).
1038        //
1039        // Releasing the claim is what preserves issue #52's documented behaviour:
1040        // the next number is re-derived from the live set, so deleting the
1041        // highest-numbered issue lets its number be reused. Without this the ledger
1042        // holds that number forever, the next create climbs past it, and the feature
1043        // stops working — which is a regression the existing test caught.
1044        //
1045        // The residual window, stated rather than hidden: if a `create` is in flight
1046        // holding this claim while the `delete` runs, the claim is released and a
1047        // later create could take the number too. That needs a delete and a create to
1048        // interleave, and it was *unconditionally* broken before this ledger existed;
1049        // the ledger removes the plain concurrent-create case, which is the one that
1050        // actually happens. Repairing the delete/create interleave would need a lock,
1051        // and this is the reason the ledger was chosen over one.
1052        let freed = std::fs::read_to_string(&path)
1053            .ok()
1054            .and_then(|text| frontmatter_number(&text));
1055        std::fs::remove_file(&path)?;
1056        if let Some(number) = freed {
1057            NumberClaim::release_number(&self.issues_dir, number);
1058        }
1059        // Best-effort cleanup of the `<slug>-assets/` dir (FR-008): the `.md`
1060        // removal is the contract, so an asset-dir removal failure is only
1061        // reported as a warning, never propagated.
1062        if let Some((segment, basename)) = split_relative(filename) {
1063            let stem = basename.strip_suffix(".md").unwrap_or(&basename);
1064            let slug = slug_from_stem(stem);
1065            let asset_dir = self
1066                .issues_dir
1067                .join(&segment)
1068                .join(format!("{slug}-assets"));
1069            if asset_dir.exists()
1070                && let Err(e) = std::fs::remove_dir_all(&asset_dir)
1071            {
1072                eprintln!(
1073                    "warning: failed to remove asset dir {}: {e}",
1074                    asset_dir.display()
1075                );
1076            }
1077        }
1078        Ok(())
1079    }
1080
1081    /// **The** status write path (WP03, C-003, FR-002, NFR-002).
1082    ///
1083    /// Every surface that changes an issue's column comes through here, so the
1084    /// transition service is on the path *structurally* rather than by convention:
1085    /// CLI (`tix mv`, `tix edit --status`), the TUI's move and edit actions, MCP's
1086    /// `move_issue` and `update_issue`, and sync's import. A caller that judges a
1087    /// transition itself is a parity defect, and the two that used to are the
1088    /// reason this function had no production caller at all.
1089    ///
1090    /// ## The three outcomes
1091    ///
1092    /// | Result | Meaning | Written |
1093    /// |---|---|---|
1094    /// | `Ok(outcome)`, `outcome.written` | accepted, changed column | file + one decision |
1095    /// | `Ok(outcome)`, `!outcome.written` | accepted, same column | **nothing** (idempotent) |
1096    /// | `Err(TixError::Workflow(_))` | refused | **nothing at all** |
1097    ///
1098    /// The separation is in the type, not in a runtime check: a refusal is an
1099    /// `Err` carrying the **named** diagnostic, so a caller cannot read it as a
1100    /// write, and cannot mistake `unknown_column` (a typo) for `invalid_transition`
1101    /// (policy) — one message shape for both would lose the distinction the
1102    /// contract's shared vocabulary exists to keep (contract §6).
1103    ///
1104    /// ## Rejection is inert
1105    ///
1106    /// Nothing is touched before the service has accepted: no issue file, no
1107    /// `tix.toml`, no History entry. The service is pure (`src/workflow.rs` is
1108    /// filesystem-free by design), and the only write in this function is the
1109    /// single `update` at the end — so there is no window in which a refusal
1110    /// leaves a partial change behind.
1111    ///
1112    /// ## No repair path
1113    ///
1114    /// A file hand-edited into an inconsistent status is read, judged, and
1115    /// reported. This function never corrects one (C-005, decision
1116    /// `01M3C3XBMTR5WFV7059NXM04AJ`); WP06 owns the diagnosis. An auto-repair
1117    /// sneaking in here would be an escalation trigger.
1118    ///
1119    /// Routes through [`Store::update`] rather than serialising directly, so the
1120    /// retired `links:` disclosure happens on this path too. Cycle-5 Minor 9: this
1121    /// used to write the file itself, which drops the retired `links:` key
1122    /// because the key is not in the model — with **no disclosure**, since the
1123    /// disclosure lives in `update`. It was dead code reachable only from tests,
1124    /// so nothing caught it, and wiring it up would have silently destroyed the
1125    /// key on every surface at once.
1126    pub fn set_status(&self, filename: &str, request: StatusChange<'_>) -> Result<StatusOutcome> {
1127        let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
1128
1129        // Once per config load, not per issue: the contract's O(V) sweep rests on
1130        // this being a single hash lookup per column rather than a rebuild.
1131        let (_, resolution) = crate::workflow::active_resolution(&self.config);
1132
1133        // The one transition evaluation (C-003). Everything below is the write this
1134        // WP is responsible for; no rule of its own lives here.
1135        let outcome = crate::workflow::validate_transition(
1136            &issue,
1137            request.column,
1138            &resolution,
1139            &self.config,
1140            request.override_reason,
1141        );
1142        // The inert case. Return before any mutation, so there is nothing to undo
1143        // and no History entry to retract.
1144        let accepted = match TransitionAccepted::try_from(outcome) {
1145            Ok(accepted) => accepted,
1146            Err(diagnostic) => return Err(TixError::Workflow(diagnostic)),
1147        };
1148
1149        // Idempotency: a same-column re-apply is accepted but writes nothing.
1150        // `updated` is deliberately *not* bumped either — a no-op that touches the
1151        // file is not a no-op, and bumping the date would make `tix edit --status`
1152        // with the column it already has look like an edit.
1153        if !accepted.changed {
1154            return Ok(StatusOutcome {
1155                issue,
1156                decision: None,
1157                written: false,
1158            });
1159        }
1160
1161        // The completion coupling, which used to live in `mv.rs`. It belongs here
1162        // because it is a rule about the *status* column: reaching completion
1163        // records a commit, and leaving it clears the one it recorded. Keeping it
1164        // in the command layer is what let `tix edit --status` move an issue to
1165        // done without recording a commit, i.e. a second definition of "done".
1166        //
1167        // WP04: both halves of this now ask the **service** whether a column is
1168        // complete, rather than comparing against `effective_done_column()`.
1169        //
1170        // This was a live defect, not a tidy-up. WP03 wrote the coupling against the
1171        // column name; WP04 moved the CLI's *prompt trigger* to the service's
1172        // decision. The two then disagreed on any board whose `category_map` puts
1173        // `completed` somewhere other than its `done_column`: the CLI correctly
1174        // decided the move reached completion and asked for a commit hash, and the
1175        // seam then compared the column to `done_column`, concluded it had not, and
1176        // **silently dropped the hash the user had just typed**. The commit-prompt
1177        // parity test is what found it.
1178        //
1179        // `was_complete` is computed before `issue.status` is reassigned below, so
1180        // it describes where the issue *was*.
1181        let was_complete = crate::workflow::is_completed(&issue, &resolution);
1182        let reaches_complete = resolution
1183            .resolve(&accepted.to)
1184            .is_some_and(crate::workflow::WorkflowCategory::is_complete);
1185        if reaches_complete {
1186            issue.commit = request.commit.map(str::to_string);
1187        } else if was_complete {
1188            issue.commit = None;
1189        }
1190
1191        issue.status = Status::new(accepted.to.clone());
1192        issue.bump_updated();
1193
1194        // Exactly one decision per accepted transition (C-006), appended to the
1195        // issue's existing History — never a second log.
1196        let decision = TransitionDecision::new(
1197            crate::model::utc_now_rfc3339(),
1198            // The existing identity path, not a new one. `actor_identity` is the
1199            // same function the relationship and hierarchy events use, so a
1200            // decision's actor is spelled the way every other History entry's is
1201            // and cannot drift from it (T012 step 4).
1202            actor_identity(),
1203            accepted.from.clone(),
1204            accepted.to.clone(),
1205            accepted.from_category.key(),
1206            accepted.to_category.key(),
1207            accepted.override_reason,
1208        );
1209        issue.history.append(decision.to_event());
1210
1211        self.update(filename, &issue)?;
1212        Ok(StatusOutcome {
1213            issue,
1214            decision: Some(decision),
1215            written: true,
1216        })
1217    }
1218
1219    /// Build a [`RelationshipGraph`] over every issue in the store.
1220    ///
1221    /// The service is the only place relationship semantics live, so the write
1222    /// path validates through it rather than re-deriving direction handling,
1223    /// cycle detection, or blocked state locally (C-003).
1224    /// The board's configured completion column.
1225    ///
1226    /// Read from the first configured board. Completion is always resolved
1227    /// from configuration, never by matching the literal string `done`; when no
1228    /// board configures one, the service's `done` fallback applies.
1229    ///
1230    /// Resolved from the **active** board — the one named `default`, falling back
1231    /// to the first declared, which is exactly what
1232    /// [`Config::default_config`](crate::config::Config::default_config) returns
1233    /// and what every other single-surface consumer already uses.
1234    ///
1235    /// It used to take the **first declared** board instead
1236    /// (`configurations.values().next()`). That is a different board whenever a
1237    /// configuration is declared before `default` with a different
1238    /// `done_column`, and it put two different boards inside a single decision:
1239    /// `blocked_move_guard` asked "is the target the completion column?" via
1240    /// `completion_column(config)` — the active board — and then "is this issue
1241    /// blocked?" via this function's graph — the *first declared* board. The
1242    /// guard was reasoning about two different workflows in one call.
1243    ///
1244    /// `data-model.md` §4 says completion "is always resolved through the board's
1245    /// configured `done_column`" and that the predicate is "the only definition
1246    /// used by FR-003, FR-004, the move guard, and every surface". That requires
1247    /// one board, not "whichever is declared first".
1248    pub fn relationship_done_column(&self) -> String {
1249        // WP09: the service's completion column. The name says `done_column` because
1250        // that is what the `RelationshipGraph` and `hierarchy` services take as a
1251        // parameter — re-deriving the *meaning* inside them is what this mission
1252        // ended; supplying that meaning from one place is the whole of the fix, and
1253        // it does not change either service's signature.
1254        //
1255        // Before: `effective_done_column().unwrap_or("done")`, so on a board whose
1256        // `category_map` puts `completed` on a column other than `done_column` the
1257        // blocked marker and the hierarchy rollup were computed against the wrong
1258        // column. `unwrap_or("done")` also invented a completion column on a board
1259        // that declared none.
1260        crate::workflow::completion_column_or_default(self.config.default_config(), &self.config)
1261    }
1262
1263    /// Create the typed relationship `a --kind--> b`.
1264    ///
1265    /// Selectors resolve with the same grammar as the selector-taking
1266    /// commands. The edge is stored **once**, in the frontmatter of whichever
1267    /// issue owns the canonical edge — the `from` side for a directional kind,
1268    /// and the lower-numbered side for `related`, which is order-normalized so
1269    /// both spellings of an association are one edge. No back-reference is
1270    /// written on the other issue: that symmetry was the retired model.
1271    ///
1272    /// Validation runs through the service first, so a self-link, a missing
1273    /// target, a conflicting kind, or a cycle is refused **before any file is
1274    /// touched** (SC-005). Re-asserting an edge that already exists is an
1275    /// idempotent no-op: no file is written and no History event is appended.
1276    pub fn link(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
1277        let issue_a = self.resolve_issue(a)?;
1278        let issue_b = self.resolve_issue(b)?;
1279        // The graph borrows the loaded issues, so both are built here and stay
1280        // alive until this function returns. The service is the only place
1281        // relationship semantics live (C-003).
1282        let issues = self.list_all()?;
1283        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1284        let done_column = self.relationship_done_column();
1285        let graph = RelationshipGraph::new(&views, &done_column);
1286
1287        let verdict = graph.validate_create(issue_a.number, issue_b.number, kind);
1288        if verdict.blocking() {
1289            return Err(relationship_error(&verdict.errors));
1290        }
1291
1292        // Idempotency goes through the service's own lookup, so an inverse
1293        // spelling or a non-canonical stored order still matches.
1294        if let Some(existing) = graph.find_canonical(issue_a.number, issue_b.number, kind) {
1295            // Nothing is written and no History event is appended. The **real
1296            // row** is reported rather than a synthetic one: the contract defines
1297            // the output as the accepted relationship, and an empty id is not
1298            // addressable by `edit_relation`/`remove_relation`, so returning one
1299            // hands the caller a dangling reference (F15).
1300            return Ok(RelationshipOutcome {
1301                written: None,
1302                relationship: existing.clone(),
1303                changed: false,
1304            });
1305        }
1306
1307        let canonical = CanonicalEdge::new(issue_a.number, issue_b.number, kind);
1308        let mut owner = self.get(IssueSelector::Number(canonical.from))?;
1309        let target_title = self
1310            .get(IssueSelector::Number(canonical.to))
1311            .map(|i| i.title)
1312            .unwrap_or_default();
1313        let relationship =
1314            Relationship::new(canonical.from, canonical.to, canonical.kind, &target_title);
1315        let detail = format!("created {}: {}", relationship.id, relationship.display);
1316        owner.relationships.push(relationship.clone());
1317        owner.history.append_now(
1318            &actor_identity(),
1319            HistoryEventKind::RelationshipAdded,
1320            &detail,
1321        );
1322        owner.bump_updated();
1323        self.update(&owner.filename, &owner)?;
1324
1325        Ok(RelationshipOutcome {
1326            written: Some(owner),
1327            relationship,
1328            changed: true,
1329        })
1330    }
1331
1332    // -- T008 — hierarchy writes ------------------------------------------
1333    //
1334    // Validate-then-write, exactly as `link` does it. The service owns the rule
1335    // and produces the History event; this layer performs the write. A blocking
1336    // result writes nothing and appends nothing (contract §4, SC-004).
1337
1338    /// The in-memory snapshot the hierarchy service reads.
1339    ///
1340    /// `pub` because the `--parent` edit flag (WP03) must validate through the
1341    /// service before any write, exactly as this module does. It is a read, so
1342    /// exposing it costs no more than a duplicate `list()` would.
1343    ///
1344    /// One `IssueView` per issue, shared with the relationship service rather than
1345    /// a second projection — two views of one issue type is how two services end up
1346    /// disagreeing about the same snapshot.
1347    ///
1348    /// The `Vec` is returned rather than discarded because the write path needs the
1349    /// child **from this same snapshot**. Re-reading it through a second `list()`
1350    /// would give the write and the validation two different notions of "current":
1351    /// two files carrying the same `number:` (reachable by hand-edit, since
1352    /// `HierarchyIndex::build` keys by number and last-write-wins while
1353    /// `Store::get` is first-match), or a concurrent writer, would make the
1354    /// service decide on one parent and the writer emit another. The invariant is
1355    /// then not merely unasserted, it is false.
1356    pub fn hierarchy_index(&self) -> Result<(HierarchyIndex, Vec<Issue>)> {
1357        let issues = self.list_all()?;
1358        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1359        Ok((HierarchyIndex::build(&views), issues))
1360    }
1361
1362    /// The child, taken from the snapshot the service just validated against.
1363    ///
1364    /// Returns an error rather than a default so a snapshot that somehow lacks the
1365    /// child is a loud failure, not a silent write of an unrelated file.
1366    fn child_in(snapshot: &[Issue], number: u32) -> Result<Issue> {
1367        snapshot
1368            .iter()
1369            .find(|i| i.number == number)
1370            .cloned()
1371            .ok_or_else(|| {
1372                TixError::NotFound(format!(
1373                    "issue #{number} vanished between validating and writing it"
1374                ))
1375            })
1376    }
1377
1378    /// Point `child` at `parent`, or repoint it if it already has one.
1379    ///
1380    /// Both selectors go through [`Store::resolve_issue`], so `#4`, `4` and a title
1381    /// fragment all work and an unresolvable one produces the CLI's own message. The
1382    /// **number** is what lands in the file, because a title is not stable.
1383    pub fn set_parent(&self, child: &str, parent: &str) -> Result<ParentOutcome> {
1384        let child_issue = self.resolve_issue(child)?;
1385        let parent_issue = self.resolve_issue(parent)?;
1386        let (index, issues) = self.hierarchy_index()?;
1387        let change = hierarchy::set_parent(&index, child_issue.number, parent_issue.number)?;
1388        self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1389    }
1390
1391    /// Repoint `child` at a new parent. A no-op when the parent is unchanged.
1392    pub fn change_parent(&self, child: &str, parent: &str) -> Result<ParentOutcome> {
1393        let child_issue = self.resolve_issue(child)?;
1394        let parent_issue = self.resolve_issue(parent)?;
1395        let (index, issues) = self.hierarchy_index()?;
1396        let change = hierarchy::change_parent(&index, child_issue.number, parent_issue.number)?;
1397        self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1398    }
1399
1400    /// Remove `child`'s parent, yielding the absent state.
1401    pub fn clear_parent(&self, child: &str) -> Result<ParentOutcome> {
1402        let child_issue = self.resolve_issue(child)?;
1403        let (index, issues) = self.hierarchy_index()?;
1404        let change = hierarchy::clear_parent(&index, child_issue.number)?;
1405        self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1406    }
1407
1408    /// Apply an accepted change: write the field and append exactly one event.
1409    ///
1410    /// The single write path for all three operations, so they cannot drift in
1411    /// whether they record what they did.
1412    /// The `issue` must come from the same snapshot the service validated against;
1413    /// see [`Store::hierarchy_index`]. That is what makes the assert below sound.
1414    fn commit_parent_change(
1415        &self,
1416        mut issue: Issue,
1417        change: hierarchy::ParentChange,
1418    ) -> Result<ParentOutcome> {
1419        let Some(event) = change.event else {
1420            // A no-op: nothing is written, nothing is read for write-back, and no
1421            // event is appended. `change.parent` is still the real current state,
1422            // because the service derived it from the snapshot, so the caller does
1423            // not have to re-read to learn it.
1424            return Ok(ParentOutcome {
1425                written: None,
1426                parent: change.parent,
1427                event: None,
1428                changed: false,
1429            });
1430        };
1431        // Read the previous value **before** overwriting it. Building the detail
1432        // after the assignment compares the new value with itself, and every
1433        // repoint was recorded as `parent #7 -> #7` — an audit trail that names
1434        // neither the old parent nor the fact that anything moved.
1435        let previous = issue.parent;
1436        let kind = HistoryEventKind::from_parent(event.kind);
1437        // The detail names both sides so a reader can follow the change without
1438        // reconstructing state from the surrounding entries. Rendered by the
1439        // service, because the `--parent` edit flag needs the identical string and
1440        // two copies of a spelling rule drift.
1441        let detail = hierarchy::change_detail(previous, change.parent);
1442        // Sound now, and it is the service's rule rather than storage's: `issue`
1443        // comes from the validated snapshot, so `previous` is by construction what
1444        // the service compared against. It was a `debug_assert_ne!` on a value
1445        // re-read through a *second* snapshot, where two files sharing a `number:`
1446        // made it fire on a correct write — panicking every dev build — and where
1447        // release builds compiled it out and wrote the contentless
1448        // `parent #N -> #N` event it was there to catch. The comment on
1449        // [`Store::hierarchy_index`] is the reason it cannot drift again.
1450        debug_assert_ne!(
1451            previous, change.parent,
1452            "an accepted change must alter the parent, or the event records nothing"
1453        );
1454        // The field only. **Nothing is written to the parent's file**: child
1455        // membership is derived by inverting this pointer, never stored (C-003).
1456        issue.parent = change.parent;
1457        issue.history.append_now(&actor_identity(), kind, &detail);
1458        issue.bump_updated();
1459        self.update(&issue.filename, &issue)?;
1460        Ok(ParentOutcome {
1461            written: Some(issue),
1462            parent: change.parent,
1463            event: Some(event),
1464            changed: true,
1465        })
1466    }
1467
1468    /// Every relationship touching `number`, as the service resolves it.
1469    ///
1470    /// **This is what a read surface must render**, not `issue.relationships`.
1471    /// A relationship is stored once, in its canonical source's frontmatter, so
1472    /// the stored field is a *partial* view of one endpoint: for `2 related 1`
1473    /// the edge lives in #1's file, so #2's own `relationships` is empty even
1474    /// though #2 plainly has a relationship. Reading the raw field made `tix
1475    /// view 2` and the TUI detail pane show nothing where the service reports
1476    /// one, which is a user-visible regression against the retired symmetric
1477    /// model — the old field was mirrored on both sides by construction.
1478    ///
1479    /// Routing every surface through one accessor is also what keeps C-003
1480    /// honest: derived relationship state is computed in exactly one place.
1481    pub fn relationships_for(&self, number: u32) -> Result<Vec<Relationship>> {
1482        let issues = self.list_all()?;
1483        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1484        let done_column = self.relationship_done_column();
1485        let graph = RelationshipGraph::new(&views, &done_column);
1486        Ok(graph
1487            .relationships_for(number)
1488            .into_iter()
1489            .cloned()
1490            .collect())
1491    }
1492
1493    /// Remove the typed relationship `a --kind--> b`.
1494    ///
1495    /// Removing an edge that is not there is an idempotent no-op: no file is
1496    /// written and no History event is appended.
1497    pub fn unlink(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
1498        let canonical = self.canonical_request(a, b, kind)?;
1499        let stored = self.find_stored_rows(&canonical)?;
1500
1501        if stored.is_empty() {
1502            // Idempotent no-op: nothing is written and no History event is
1503            // appended. The reported relationship is the canonical form the
1504            // caller asked about.
1505            return Ok(RelationshipOutcome {
1506                written: None,
1507                relationship: canonical_placeholder(&canonical),
1508                changed: false,
1509            });
1510        }
1511
1512        // Remove every row matching the canonical edge, wherever it is stored,
1513        // and record **one** History event for the accepted mutation. Redundant
1514        // rows are one invalid condition to clean up, not N mutations
1515        // (contract §6: one event per accepted mutation).
1516        let mut by_file: HashMap<String, Vec<Relationship>> = HashMap::new();
1517        for (filename, rel) in stored {
1518            by_file.entry(filename).or_default().push(rel);
1519        }
1520
1521        let actor = actor_identity();
1522        // Ascending filename, for the same reproducibility reason as above.
1523        let mut file_order: Vec<String> = by_file.keys().cloned().collect();
1524        file_order.sort_unstable();
1525
1526        // The first row this call removes, captured before anything is written.
1527        // M11: a re-scan *after* removal can never match — every matching row is
1528        // gone — so a successful unlink always fell through to the empty
1529        // placeholder and reported an unaddressable relationship, while `link`'s
1530        // no-op path reported a real one. F15 was applied to one path, not both.
1531        let mut removed_row: Option<Relationship> = None;
1532
1533        // M10: the summary is computed **once**, from every row this call
1534        // removes, and the same event is recorded on each file that changes.
1535        // Appending per file produced two events for one `unlink` when the same
1536        // edge was hand-authored into both endpoints, while the comment above
1537        // claimed one event per accepted mutation. One unlink is one mutation.
1538        let all_removed: Vec<Relationship> = by_file.values().flatten().cloned().collect();
1539        let summary = if all_removed.len() == 1 {
1540            let one = &all_removed[0];
1541            format!("removed {}: {}", one.id, one.display)
1542        } else {
1543            let kinds: Vec<String> = all_removed.iter().map(|r| r.kind.to_string()).collect();
1544            format!(
1545                "removed {} relationships ({})",
1546                all_removed.len(),
1547                kinds.join(", ")
1548            )
1549        };
1550
1551        let mut written: Option<Issue> = None;
1552        for filename in &file_order {
1553            let doomed = &by_file[filename];
1554            let mut owner = self.get(IssueSelector::Filename(filename.clone()))?;
1555            if removed_row.is_none() {
1556                removed_row = doomed.first().cloned();
1557            }
1558            owner.relationships.retain(|r| r.canonical() != canonical);
1559            owner
1560                .history
1561                .append_now(&actor, HistoryEventKind::RelationshipRemoved, &summary);
1562            owner.bump_updated();
1563            self.update(&owner.filename, &owner)?;
1564            written = Some(owner);
1565        }
1566
1567        // The row that *was* removed, captured above — an empty id is not
1568        // addressable by `edit_relation`/`remove_relation`.
1569        let relationship = removed_row.unwrap_or_else(|| canonical_placeholder(&canonical));
1570
1571        Ok(RelationshipOutcome {
1572            written,
1573            relationship,
1574            changed: true,
1575        })
1576    }
1577
1578    /// Retarget and/or re-kind the relationship with this stable id.
1579    ///
1580    /// The id is generated once and **survives** the edit, so a relationship a
1581    /// user has already referenced elsewhere does not change identity when its
1582    /// target moves (contract §1). `new_to` and `new_kind` are both optional and
1583    /// naming neither is the identity edit: nothing is written and no History
1584    /// event is appended.
1585    ///
1586    /// Validation goes through [`RelationshipGraph::validate_edit`], so an edit
1587    /// that would self-link, point at a missing issue, duplicate an existing
1588    /// edge, or close a cycle is refused **before any file is touched**
1589    /// (SC-005). An edit may change the *kind* but never the source: the source
1590    /// is the edge's owner, and moving it would mean deleting a relationship and
1591    /// creating another, which is what `remove` plus `link` is for.
1592    pub fn edit_relation(
1593        &self,
1594        id: &str,
1595        new_to: Option<u32>,
1596        new_kind: Option<RelationKind>,
1597    ) -> Result<RelationshipOutcome> {
1598        let issues = self.list_all()?;
1599        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1600        let done_column = self.relationship_done_column();
1601        let graph = RelationshipGraph::new(&views, &done_column);
1602
1603        let Some(existing) = graph.find_relationship(id) else {
1604            // An id that does not resolve is a named diagnostic, not a bare
1605            // `NotFound`: the user passed a well-formed argument, and what is
1606            // missing is a relationship, not an issue.
1607            let mut verdict = crate::relationships::RelationshipValidationResult::clean();
1608            verdict.errors.push(Diagnostic::unknown_relation(id));
1609            return Err(relationship_error(&verdict.errors));
1610        };
1611
1612        let verdict = graph.validate_edit(id, new_to, new_kind);
1613        if verdict.blocking() {
1614            return Err(relationship_error(&verdict.errors));
1615        }
1616
1617        let to = new_to.unwrap_or(existing.to);
1618        let kind = new_kind.unwrap_or(existing.kind);
1619        let old_canonical = existing.canonical();
1620
1621        // The new form has to be **canonicalized before anything else is derived
1622        // from it**, not merely validated. `--type blocked_by` and re-kinding
1623        // `related` change which issue owns the edge *and* which issue it points
1624        // at: `A --blocked_by--> B` is stored as `B blocks A`, so the canonical
1625        // target is the authored `from`, not the authored `to`.
1626        //
1627        // Deriving the display name from the *authored* target is what produced
1628        // `blocks #2 Issue A` for a `2 duplicates 0` edge re-kinded to
1629        // `blocked_by`: right endpoints, wrong title, and a stored row that lies
1630        // about the issue it names. Canonicalize first, then read the title off
1631        // the canonical target.
1632        let requested = CanonicalEdge::new(existing.from, to, kind);
1633        let mut updated = existing.clone();
1634        updated.from = requested.from;
1635        updated.to = requested.to;
1636        updated.kind = requested.kind;
1637
1638        // The target's *current* title, from the canonical target. A target that
1639        // does not resolve would otherwise store an edge with an empty display
1640        // name, so this stays lenient and the row carries what it can.
1641        let target_title = self
1642            .get(IssueSelector::Number(updated.to))
1643            .map(|i| i.title)
1644            .unwrap_or_default();
1645        updated.refresh_display(&target_title);
1646        let new_canonical = updated.canonical();
1647
1648        // No-op detection is on the *resolved* edge, not on the arguments: an
1649        // edit naming its own current target and kind is the identity, and so is
1650        // an edit that only re-renders the same display.
1651        if old_canonical == new_canonical && updated.display == existing.display {
1652            return Ok(RelationshipOutcome {
1653                written: None,
1654                relationship: existing.clone(),
1655                changed: false,
1656            });
1657        }
1658
1659        let actor = actor_identity();
1660        let detail = format!("{} → {}: {}", existing.display, updated.display, updated.id);
1661
1662        // Remove the old row from every file that holds it, without recording
1663        // a `relationship_removed` event: this is one edit, not a delete plus a
1664        // create. Only the canonical source is written when ownership does not
1665        // move, so the common case stays a single-file write.
1666        // Removal and insertion are **separate passes**, because the canonical
1667        // source of the new form is not necessarily a file that held the old
1668        // one. Re-kinding can move ownership — `related` is order-normalised and
1669        // `blocked_by` swaps the endpoints — and when it does, a loop that both
1670        // removes and re-adds deletes the row from its old owner and never
1671        // writes it to the new one. The relationship is then destroyed while the
1672        // call reports success, and a `blocked_by` re-kind reports a `blocks`
1673        // edge that does not exist, so every derived read is wrong the same way.
1674        //
1675        // Two passes make that shape unrepresentable: the first only ever
1676        // removes, the second only ever adds.
1677        let mut removed_anywhere = false;
1678        for issue in self.list_all()? {
1679            if !issue
1680                .relationships
1681                .iter()
1682                .any(|r| r.id == id && r.canonical() == old_canonical)
1683            {
1684                continue;
1685            }
1686            let mut owner = self.get(IssueSelector::Filename(issue.filename.clone()))?;
1687            owner
1688                .relationships
1689                .retain(|r| !(r.id == id && r.canonical() == old_canonical));
1690            removed_anywhere = true;
1691            owner.bump_updated();
1692            self.update(&owner.filename, &owner)?;
1693        }
1694        debug_assert!(removed_anywhere, "graph found the row, the scan did not");
1695
1696        // The new canonical source carries the row and the History event. It may
1697        // be the file just written, a different file, or one the old form never
1698        // touched, so all three cases are written here rather than inside the
1699        // removal loop.
1700        let mut new_owner = self.get(IssueSelector::Number(new_canonical.from))?;
1701        new_owner.relationships.push(updated.clone());
1702        new_owner
1703            .history
1704            .append_now(&actor, HistoryEventKind::RelationshipRetargeted, &detail);
1705        new_owner.bump_updated();
1706        self.update(&new_owner.filename, &new_owner)?;
1707
1708        Ok(RelationshipOutcome {
1709            written: Some(self.get(IssueSelector::Number(new_canonical.from))?),
1710            relationship: updated,
1711            changed: true,
1712        })
1713    }
1714
1715    /// Remove the relationship with this stable id.
1716    ///
1717    /// An id that is not stored is an **idempotent no-op**: no file is written
1718    /// and no History event is appended, exactly as [`Store::unlink`] treats an
1719    /// absent edge. `tix link remove` is therefore safe to retry.
1720    pub fn remove_relation(&self, id: &str) -> Result<RelationshipOutcome> {
1721        let stored = self.find_stored_rows_by_id(id)?;
1722        if stored.is_empty() {
1723            // Reported as a no-op rather than an error, and the reported
1724            // relationship carries the id the caller asked about so the outcome
1725            // is addressable in the message.
1726            return Ok(RelationshipOutcome {
1727                written: None,
1728                relationship: Relationship {
1729                    id: id.to_string(),
1730                    ..canonical_placeholder(&CanonicalEdge::new(0, 0, RelationKind::Related))
1731                },
1732                changed: false,
1733            });
1734        }
1735
1736        let canonical = stored[0].1.canonical();
1737        // Reuse the edge-keyed removal so both spellings of a remove stay one
1738        // implementation, including redundant-row cleanup and the single
1739        // History event per accepted mutation.
1740        let from = canonical.from.to_string();
1741        let to = canonical.to.to_string();
1742        self.unlink(&from, &to, canonical.kind)
1743    }
1744
1745    /// Every stored row with this id, paired with the store-relative filename
1746    /// it lives in.
1747    ///
1748    /// Ids are unique per relationship, but a hand-edited file can hold the same
1749    /// id twice, so this returns all of them and the caller removes all of them.
1750    fn find_stored_rows_by_id(&self, id: &str) -> Result<Vec<(String, Relationship)>> {
1751        let mut found = Vec::new();
1752        for issue in self.list_all()? {
1753            for rel in &issue.relationships {
1754                if rel.id == id {
1755                    found.push((issue.filename.clone(), rel.clone()));
1756                }
1757            }
1758        }
1759        Ok(found)
1760    }
1761
1762    /// The issue set plus the board's configured completion column, for
1763    /// building a [`RelationshipGraph`] over the whole store.
1764    ///
1765    /// The issue set is returned **alongside** the column rather than as a
1766    /// finished graph because the graph borrows the issues: a caller that wants
1767    /// three derived reads (`relationships_for`, `blocked_state`,
1768    /// `dependency_chain`) over one snapshot would otherwise pay three
1769    /// separate `O(V + E)` builds and risk the three disagreeing.
1770    ///
1771    /// This is the single place a read surface obtains its graph, so no surface
1772    /// can reach past the service to re-derive direction handling, cycle
1773    /// detection, or blocked state (C-003).
1774    ///
1775    /// Reads the **active** set ([`Store::list`]): it is the graph a default
1776    /// view renders — `tix list --show relationships`, `tix relationship list`
1777    /// — so archived issues are deliberately absent from it (issue #1492).
1778    /// Per-issue lookups that must still resolve an archived issue go through
1779    /// [`Store::relationships_for`], which reads [`Store::list_all`].
1780    pub fn graph_source(&self) -> Result<(Vec<Issue>, String)> {
1781        Ok((self.list()?, self.relationship_done_column()))
1782    }
1783
1784    /// Resolve a requested pair into the canonical edge it refers to, without
1785    /// requiring the *target* to exist.
1786    ///
1787    /// A dangling edge is a diagnosed, expected condition (`data-model.md` §4,
1788    /// contract §7), so the removal path must still be able to reach it. Both
1789    /// selectors are therefore resolved leniently: a selector that fails to
1790    /// resolve is accepted if it is a bare issue number, because that is enough
1791    /// to canonicalize the request. Anything else is still `NotFound`.
1792    fn canonical_request(&self, a: &str, b: &str, kind: RelationKind) -> Result<CanonicalEdge> {
1793        let lenient = |sel: &str| -> Result<u32> {
1794            match self.resolve_issue(sel) {
1795                Ok(issue) => Ok(issue.number),
1796                Err(e) => sel.trim().parse::<u32>().map_err(|_| e),
1797            }
1798        };
1799        let from = lenient(a)?;
1800        let to = lenient(b)?;
1801        Ok(CanonicalEdge::new(from, to, kind))
1802    }
1803
1804    /// Every stored row matching `canonical`, paired with the store-relative
1805    /// filename it actually lives in.
1806    ///
1807    /// This is the single answer to "where is this edge stored", and it is the
1808    /// reason the three entry points no longer disagree. It matches on
1809    /// [`Relationship::canonical`] — the service's own normalization — rather
1810    /// than comparing authored fields, so a row is found no matter which file
1811    /// holds it or which endpoint order it was written in. Two defects came
1812    /// from re-deriving that comparison locally: a hand-authored
1813    /// non-canonically-ordered `related` row was un-removable, and a row
1814    /// persisted with the input-only `blocked_by` spelling was un-removable too.
1815    ///
1816    /// Cost is one `O(V + E)` pass, the same order as the validation the write
1817    /// path already performs. Correctness of "which file holds this edge" is
1818    /// worth it; a wrong answer here silently deletes nothing.
1819    fn find_stored_rows(&self, canonical: &CanonicalEdge) -> Result<Vec<(String, Relationship)>> {
1820        let mut found = Vec::new();
1821        for issue in self.list_all()? {
1822            for rel in &issue.relationships {
1823                if rel.canonical() == *canonical {
1824                    found.push((issue.filename.clone(), rel.clone()));
1825                }
1826            }
1827        }
1828        Ok(found)
1829    }
1830
1831    /// Make the issue's relationship set exactly `targets`, with `kind`.
1832    ///
1833    /// A typed relationship is stored **once, in its canonical source's file**.
1834    /// For a directional kind that is the declaring issue; for `related`, which
1835    /// is order-normalized, it is the lower-numbered of the pair. So this method
1836    /// may have to write more than one file: an edge requested from an issue
1837    /// that is not its canonical source belongs to the other issue's frontmatter.
1838    /// That is the same ownership rule [`Store::link`] already follows, and it
1839    /// is what "stored once, no mirrored edge" requires.
1840    ///
1841    /// Every target number must already resolve (`NotFound` otherwise) and a
1842    /// self-link is refused — both before anything is written (SC-005).
1843    /// Relationships that survive keep their stable id and their History
1844    /// entries; removals are recorded.
1845    pub fn set_relationships(
1846        &self,
1847        filename: &str,
1848        targets: &[(u32, RelationKind)],
1849    ) -> Result<Issue> {
1850        let issue = self.get(IssueSelector::Filename(filename.to_string()))?;
1851        let own = issue.number;
1852        let actor = actor_identity();
1853
1854        // Validate every target up front, before any mutation (SC-005).
1855        let mut wanted: Vec<CanonicalEdge> = Vec::with_capacity(targets.len());
1856        for (target, kind) in targets {
1857            if *target == own {
1858                return Err(TixError::InvalidInput(format!(
1859                    "issue #{own} cannot relate to itself"
1860                )));
1861            }
1862            self.get(IssueSelector::Number(*target))?;
1863            let canonical = CanonicalEdge::new(own, *target, *kind);
1864            // Identical canonical triples collapse, so asking for the same edge
1865            // twice is not two writes and not a self-conflict.
1866            if !wanted.contains(&canonical) {
1867                wanted.push(canonical);
1868            }
1869        }
1870
1871        // Every wanted edge is validated **through the service** before the
1872        // first write, exactly as `link` does. This used to re-implement
1873        // existence and the self-link rule locally and skip the rest, so with
1874        // `1 blocks 2` already on disk it would happily store `2 blocks 1` — a
1875        // reciprocal pair that `link` rejects one line earlier and that
1876        // contract §3 rules 3 and 4 require be refused. Re-deriving part of the
1877        // rule set is also the boundary breach C-003 exists to prevent, and it
1878        // becomes user-reachable the moment WP03 wires `--type`.
1879        {
1880            let issues = self.list_all()?;
1881            let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1882            let done_column = self.relationship_done_column();
1883            let graph = RelationshipGraph::new(&views, &done_column);
1884            for canonical in &wanted {
1885                let verdict = graph.validate_create(canonical.from, canonical.to, canonical.kind);
1886                if verdict.blocking() {
1887                    return Err(relationship_error(&verdict.errors));
1888                }
1889            }
1890        }
1891
1892        // Remove: every existing relationship this issue owns that is not
1893        // wanted, grouped by the file that actually stores it.
1894        //
1895        // "Owns" has two cases, and getting this wrong is a silent data bug:
1896        //
1897        //  - The canonical `from` is this issue. True for every directional
1898        //    kind, and for `related` whenever this issue has the lower number.
1899        //  - The canonical `to` is this issue **and** the kind is
1900        //    non-directional. A `related` association belongs to both
1901        //    endpoints even though it is stored once, in the lower-numbered
1902        //    issue's file. `tix edit 5 --links ""` has to remove a
1903        //    `5 related 3` edge even though it lives in #3's frontmatter and
1904        //    #5's own file is empty.
1905        //
1906        // An *inbound directional* edge is deliberately excluded: if #3 blocks
1907        // #5, that edge is #3's declaration, and #5 clearing its own
1908        // relationship set must not silently rewrite #3's dependency graph.
1909        let issues = self.list_all()?;
1910        let mut removals: HashMap<u32, Vec<Relationship>> = HashMap::new();
1911        for candidate in &issues {
1912            for rel in &candidate.relationships {
1913                let canonical = rel.canonical();
1914                let owned_by_us = canonical.from == own
1915                    || (canonical.to == own && !canonical.kind.is_directional());
1916                if owned_by_us && !wanted.contains(&canonical) {
1917                    removals
1918                        .entry(canonical.from)
1919                        .or_default()
1920                        .push(rel.clone());
1921                }
1922            }
1923        }
1924
1925        // Add: each wanted edge goes into its canonical source's file. One
1926        // already present is left alone, so the stable id survives.
1927        let mut additions: HashMap<u32, Vec<(CanonicalEdge, String)>> = HashMap::new();
1928        for canonical in &wanted {
1929            let owner = match self.get(IssueSelector::Number(canonical.from)) {
1930                Ok(owner) => owner,
1931                Err(TixError::NotFound(_)) => continue,
1932                Err(e) => return Err(e),
1933            };
1934            if owner
1935                .relationships
1936                .iter()
1937                .any(|r| r.canonical() == *canonical)
1938            {
1939                continue;
1940            }
1941            let target_title = self
1942                .get(IssueSelector::Number(canonical.to))
1943                .map(|i| i.title)
1944                .unwrap_or_default();
1945            additions
1946                .entry(canonical.from)
1947                .or_default()
1948                .push((*canonical, target_title));
1949        }
1950
1951        if removals.is_empty() && additions.is_empty() {
1952            return Ok(issue);
1953        }
1954
1955        // Apply removals, then additions. This can write **N** files — the
1956        // removals map is keyed by whichever file actually holds each row, and
1957        // an order-normalized `related` edge is held by the lower-numbered
1958        // issue, not the one named by `filename`. A failure partway through
1959        // would therefore leave the store half-mutated, so every file's
1960        // pre-write contents are captured on the way through and restored if a
1961        // later write fails (SC-005: no file is left partially rewritten).
1962        //
1963        // The restore is best-effort per file and the original error is
1964        // returned either way: a rollback that itself fails must not mask the
1965        // cause.
1966        // Every file's pre-write contents are captured on the way through and
1967        // restored if a later write in the same call fails, so a refused
1968        // mutation leaves nothing behind (SC-005). The restore is best-effort
1969        // per file and the original error is returned either way: a rollback
1970        // that itself fails must not mask the cause.
1971        // Owners are written in ascending number order. `removals` and
1972        // `additions` are `HashMap`s, so iterating them directly makes the write
1973        // order — and therefore which files are committed before a failure —
1974        // differ between runs of the same input.
1975        let mut removal_order: Vec<u32> = removals.keys().copied().collect();
1976        removal_order.sort_unstable();
1977        let mut addition_order: Vec<u32> = additions.keys().copied().collect();
1978        addition_order.sort_unstable();
1979
1980        // Write one owner, remembering how to undo it. A nested `fn` rather than
1981        // a closure so `undo` and `touched_named` can be passed explicitly
1982        // instead of captured, which would make them unborrowable at the
1983        // call sites.
1984        fn apply(
1985            store: &Store,
1986            mut owner: Issue,
1987            undo: &mut Vec<(PathBuf, String)>,
1988            touched_named: &mut bool,
1989            named_path: &Path,
1990        ) -> Result<()> {
1991            let path = store.issues_dir.join(&owner.filename);
1992            if path == named_path {
1993                *touched_named = true;
1994            }
1995            if let Ok(previous) = std::fs::read_to_string(&path) {
1996                undo.push((path, previous));
1997            }
1998            owner.bump_updated();
1999            store.update(&owner.filename, &owner)
2000        }
2001
2002        let named_path = self.issues_dir.join(&issue.filename);
2003        let mut undo: Vec<(PathBuf, String)> = Vec::new();
2004        let mut touched_named = false;
2005
2006        let applied = (|| -> Result<()> {
2007            for owner_number in &removal_order {
2008                let doomed = &removals[owner_number];
2009                let mut owner = self.get(IssueSelector::Number(*owner_number))?;
2010                let mut changed = false;
2011                for rel in doomed {
2012                    let canonical = rel.canonical();
2013                    let before = owner.relationships.len();
2014                    owner.relationships.retain(|r| r.canonical() != canonical);
2015                    if owner.relationships.len() != before {
2016                        let detail = format!("removed {}: {}", rel.id, rel.display);
2017                        owner.history.append_now(
2018                            &actor,
2019                            HistoryEventKind::RelationshipRemoved,
2020                            &detail,
2021                        );
2022                        changed = true;
2023                    }
2024                }
2025                if changed {
2026                    apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
2027                }
2028            }
2029
2030            for owner_number in &addition_order {
2031                let wanted_here = &additions[owner_number];
2032                let mut owner = self.get(IssueSelector::Number(*owner_number))?;
2033                for (canonical, target_title) in wanted_here {
2034                    let rel = Relationship::new(
2035                        canonical.from,
2036                        canonical.to,
2037                        canonical.kind,
2038                        target_title,
2039                    );
2040                    let detail = format!("created {}: {}", rel.id, rel.display);
2041                    owner
2042                        .history
2043                        .append_now(&actor, HistoryEventKind::RelationshipAdded, &detail);
2044                    owner.relationships.push(rel);
2045                }
2046                apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
2047            }
2048
2049            // F16. The named issue's own frontmatter can be untouched while its
2050            // relationship set changed, because an order-normalized `related`
2051            // edge is stored in the *other* endpoint's file. `tix edit 2 --links
2052            // 1` then left #2 byte-identical with a stale `updated`, so the
2053            // command appeared to do nothing to the issue it named and the audit
2054            // trail lived only on the other endpoint. `updated` tracks the
2055            // issue's last mutation and its relationship set did change.
2056            //
2057            // Inside the rolled-back sequence and through the same `apply`, so
2058            // its file is captured for restore like every other. Placed after the
2059            // rollback check it was a fresh partial-write hole: a failure here
2060            // returned an error with the relationship writes already committed
2061            // and nothing left to restore.
2062            if !touched_named {
2063                let named = self.get(IssueSelector::Filename(filename.to_string()))?;
2064                apply(self, named, &mut undo, &mut touched_named, &named_path)?;
2065            }
2066            Ok(())
2067        })();
2068
2069        if let Err(e) = applied {
2070            for (path, previous) in undo.iter().rev() {
2071                let _ = atomic_write(&self.issues_dir, path, previous.as_bytes());
2072            }
2073            return Err(e);
2074        }
2075
2076        self.get(IssueSelector::Filename(filename.to_string()))
2077    }
2078
2079    /// Resolve a selector string exactly like the selector-taking commands
2080    /// (`parse_selector`) and fetch the issue (D2: "Resolver SHALL match
2081    /// `tix edit`/`tix move`").
2082    ///
2083    /// `pub` because the `--parent` flags (WP03) resolve a parent selector to its
2084    /// **number** through this same path, so `#4`, `4` and a title fragment all work
2085    /// on a parent exactly as they do on any other selector — and a second
2086    /// resolver in the command layer is how two surfaces end up disagreeing about
2087    /// which issue a selector names.
2088    pub fn resolve_issue(&self, raw: &str) -> Result<Issue> {
2089        let selector = crate::commands::parse_selector(self, raw)?;
2090        self.get(selector)
2091    }
2092
2093    /// Physically move an issue file into the assignee segment of a new
2094    /// assignee (FR-031, `contracts/file-format.md` rule 9).
2095    ///
2096    /// `filename` is a path relative to the issues dir: either a legacy flat
2097    /// basename (`2026-05-10-fix-login.md`, C-010) or a grouped path
2098    /// (`alice/2026-05-10-fix-login.md`). It is validated via the relaxed
2099    /// [`validate_filename`] before anything touches the filesystem, so
2100    /// traversal attempts are refused here as everywhere else.
2101    ///
2102    /// The old assignee segment is read from the current relative path, the
2103    /// new one derived with [`derive_segment`]; equal segments are a no-op.
2104    /// The target filename keeps the original `created-slug` stem; when the
2105    /// target already holds `<stem>.md`, a deterministic `-1`, `-2`, …
2106    /// suffix is appended by testing existence — an existing file is never
2107    /// overwritten or deleted. `number`/`created` live in the frontmatter and
2108    /// are untouched, so issue-number stability is guaranteed by construction
2109    /// (C-007).
2110    ///
2111    /// The move is a single `std::fs::rename` (atomic-ish, same filesystem).
2112    /// Returns the **new relative path** (matching how `issue.filename` is
2113    /// stored) on success, including the unchanged relative path for the
2114    /// equal-segment no-op.
2115    pub fn reassign(&self, filename: &str, new_assignee: Option<&str>) -> Result<PathBuf> {
2116        // `get` validates `filename` and errors NotFound when it is missing.
2117        self.get(IssueSelector::Filename(filename.to_string()))?;
2118
2119        let (old_segment, file_name) = split_relative(filename).ok_or_else(|| {
2120            TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
2121        })?;
2122        let new_segment = derive_segment(new_assignee);
2123        if old_segment == new_segment {
2124            return Ok(PathBuf::from(filename));
2125        }
2126
2127        let stem = file_name
2128            .strip_suffix(".md")
2129            .unwrap_or(&file_name)
2130            .to_string();
2131        let target_dir = self.issues_dir.join(&new_segment);
2132        std::fs::create_dir_all(&target_dir)?;
2133        let new_name = resolve_target_name(&target_dir, &stem);
2134        std::fs::rename(self.issues_dir.join(filename), target_dir.join(&new_name))?;
2135
2136        // Move the `<slug>-assets/` dir alongside the file (FR-007). With no
2137        // asset dir this is a no-op and reassign behaves exactly as before.
2138        // On a same-named dir in the target segment, disambiguate with a
2139        // suffixed dir name (deterministic existence probe, mirroring
2140        // `resolve_target_name`) rather than overwriting.
2141        let slug = slug_from_stem(&stem);
2142        let asset_name = format!("{slug}-assets");
2143        let old_asset_dir = self.issues_dir.join(&old_segment).join(&asset_name);
2144        if old_asset_dir.exists() {
2145            let moved = resolve_asset_dir_name(&target_dir, &asset_name);
2146            std::fs::rename(&old_asset_dir, target_dir.join(&moved))?;
2147        }
2148        Ok(PathBuf::from(format!("{new_segment}/{new_name}")))
2149    }
2150}
2151
2152/// Whether a **relative** path is one [`Store::scan`] would index as an issue
2153/// file (issue #10 follow-up).
2154///
2155/// `scan` walks the issues dir and, for each top-level directory that is not a
2156/// `.tix-tmp-*` marker dir or a `<slug>-assets/` dir, reads its direct `.md`
2157/// children; a `.md` file directly under the issues dir is a legacy flat issue
2158/// (C-010). This predicate states those rules for an arbitrary path, so the
2159/// incremental corpus path ignores exactly what a full rebuild would: a
2160/// non-`.md` file, a file in a skipped segment, or anything nested deeper than
2161/// one segment.
2162pub(crate) fn is_issue_rel_path(rel: &Path) -> bool {
2163    let mut components = rel.components();
2164    let Some(std::path::Component::Normal(segment)) = components.next() else {
2165        return false;
2166    };
2167    let file_name = match components.next() {
2168        // A legacy flat file: the single component is both segment and name.
2169        None => segment,
2170        Some(std::path::Component::Normal(name)) => {
2171            if components.next().is_some() {
2172                return false; // deeper than one segment: `scan` never walks there
2173            }
2174            let Some(segment) = segment.to_str() else {
2175                return false;
2176            };
2177            if segment.starts_with(".tix-tmp-") || segment.ends_with("-assets") {
2178                return false;
2179            }
2180            name
2181        }
2182        Some(_) => return false,
2183    };
2184    file_name.to_str().is_some_and(|name| name.ends_with(".md"))
2185}
2186
2187/// Validate an issue filename. A filename is either a legacy flat basename
2188/// (`2026-05-10-fix-login.md`) or a grouped path with exactly one assignee
2189/// segment prefix (`alice/2026-05-10-fix-login.md`, C-008/C-010).
2190///
2191/// Refused in all cases: NUL bytes, backslashes, the `..` component, empty
2192/// components (leading/trailing/double slashes), absolute / leading-slash
2193/// paths, and nesting deeper than one segment. Every refusal is a hard
2194/// [`TixError::InvalidInput`] raised before any filesystem touch.
2195fn validate_filename(filename: &str) -> Result<()> {
2196    if filename.is_empty() {
2197        return Err(TixError::InvalidInput(format!(
2198            "filename must not be empty: {filename:?}"
2199        )));
2200    }
2201    if filename.starts_with('/') {
2202        return Err(TixError::InvalidInput(format!(
2203            "filename must be relative (no leading `/`): {filename}"
2204        )));
2205    }
2206    if filename.contains('\\') {
2207        return Err(TixError::InvalidInput(format!(
2208            "filename must not contain backslashes: {filename}"
2209        )));
2210    }
2211    if filename.contains("..") {
2212        return Err(TixError::InvalidInput(format!(
2213            "filename must not contain `..`: {filename}"
2214        )));
2215    }
2216    if filename.contains('\0') {
2217        return Err(TixError::InvalidInput(format!(
2218            "filename must not contain NUL: {filename}"
2219        )));
2220    }
2221    if filename.split('/').count() > 2 {
2222        return Err(TixError::InvalidInput(format!(
2223            "filename may have at most one segment prefix: {filename}"
2224        )));
2225    }
2226    if filename.split('/').any(|component| component.is_empty()) {
2227        return Err(TixError::InvalidInput(format!(
2228            "filename path components must not be empty: {filename}"
2229        )));
2230    }
2231    Ok(())
2232}
2233
2234/// Split a validated relative filename into `(segment, basename)`. Legacy
2235/// flat filenames have an empty segment (`("", "2026-05-10-x.md")`), grouped
2236/// ones carry exactly one segment (`("alice", "2026-05-10-x.md")`). [`None`]
2237/// for anything that survived a bad path shape.
2238/// The `number:` field of an issue file's frontmatter, without a full parse.
2239///
2240/// `Store::delete` needs the number of the issue it is removing so it can free that
2241/// number's claim, and it needs it *before* the file is gone. A full `Issue` parse
2242/// would be the obvious way, but this sits on the delete path and only needs one
2243/// scalar, so a scoped scan is enough — and it cannot fail on an issue that would
2244/// fail to load, which keeps `delete`'s existing "removes the file" contract
2245/// (#1352) independent of frontmatter validity.
2246///
2247/// The scan stops at the closing `---` so a `number:` in the **body** cannot be
2248/// mistaken for the field.
2249fn frontmatter_number(text: &str) -> Option<u32> {
2250    let mut lines = text.lines();
2251    // The opening delimiter. A file that does not start with one has no frontmatter.
2252    if lines.next()?.trim() != "---" {
2253        return None;
2254    }
2255    for line in lines {
2256        let line = line.trim_end();
2257        // The closing delimiter ends the frontmatter, so a `number:` after it is
2258        // body text and must not be mistaken for the field.
2259        if line == "---" {
2260            return None;
2261        }
2262        if let Some(rest) = line.strip_prefix("number:") {
2263            return rest.trim().parse().ok();
2264        }
2265    }
2266    None
2267}
2268
2269/// The directory holding one marker per claimed number.
2270///
2271/// **A sibling of the issues directory, not a child of it.** `docs/issues/` is the
2272/// tool's source of truth: people hand-edit it, script against it, and glob it, and
2273/// its contents are committed. Adding an internal directory inside it changes what
2274/// every one of those consumers sees — a test helper that counts segments, a `for`
2275/// loop over `docs/issues/*`, a CI check — for machinery no reader should have to
2276/// know about. Beside it, `docs/issues/` contains exactly what it did before.
2277///
2278/// Derived from the configured `issues_dir` rather than hardcoded, so a repo that
2279/// points `issues_dir` somewhere else still gets a ledger beside that.
2280fn claims_dir(issues_dir: &Path) -> PathBuf {
2281    issues_dir
2282        .parent()
2283        .unwrap_or(issues_dir)
2284        .join(NumberClaim::DIR)
2285}
2286
2287fn split_relative(filename: &str) -> Option<(String, String)> {
2288    let mut parts = filename.rsplitn(2, '/');
2289    let basename = parts.next()?;
2290    let segment = parts.next().unwrap_or("");
2291    Some((segment.to_string(), basename.to_string()))
2292}
2293
2294/// Flip the archived marker on a store-relative issue filename.
2295///
2296/// `alice/2026-01-01-x.md` → `alice/.2026-01-01-x.md` when `archive` is true,
2297/// and the inverse when false. Only the basename's leading dot changes; the
2298/// segment stays put, so the file never moves between directories. `None` when
2299/// the name is already in the requested state.
2300fn flip_archived(filename: &str, archive: bool) -> Option<String> {
2301    let (segment, basename) = split_relative(filename)?;
2302    let flipped = if archive {
2303        if basename.starts_with('.') {
2304            return None;
2305        }
2306        format!(".{basename}")
2307    } else {
2308        basename.strip_prefix('.')?.to_string()
2309    };
2310    Some(if segment.is_empty() {
2311        flipped
2312    } else {
2313        format!("{segment}/{flipped}")
2314    })
2315}
2316
2317/// The first free `<stem>.md` / `<stem>-1.md` / `<stem>-2.md` … name in
2318/// `dir` (deterministic existence probe). Never returns an existing name.
2319fn resolve_target_name(dir: &Path, stem: &str) -> String {
2320    let mut candidate = format!("{stem}.md");
2321    let mut i = 1u32;
2322    while dir.join(&candidate).exists() {
2323        candidate = format!("{stem}-{i}.md");
2324        i += 1;
2325    }
2326    candidate
2327}
2328
2329/// The slug used for the asset dir: the issue filename stem minus the leading
2330/// `YYYY-MM-DD-` date prefix (`2026-09-13-fix-login-bug` → `fix-login-bug`).
2331/// When the stem has no date prefix it is used as-is (legacy fallback).
2332fn slug_from_stem(stem: &str) -> String {
2333    let bytes = stem.as_bytes();
2334    if stem.len() > 10 && bytes[4] == b'-' && bytes[7] == b'-' && bytes[10] == b'-' {
2335        stem[11..].to_string()
2336    } else {
2337        stem.to_string()
2338    }
2339}
2340
2341/// The first free `<name>` / `<name>-<n>.<ext>` name for an asset in `dir`.
2342/// When `<name>` is taken the extension is kept and a deterministic
2343/// numeric suffix is appended (FR-006).
2344fn resolve_asset_name(dir: &Path, name: &str) -> String {
2345    if !dir.join(name).exists() {
2346        return name.to_string();
2347    }
2348    let (stem, ext) = match name.rsplit_once('.') {
2349        Some((s, e)) => (s, format!(".{e}")),
2350        None => (name, String::new()),
2351    };
2352    let mut i = 1u32;
2353    loop {
2354        let candidate = format!("{stem}-{i}{ext}");
2355        if !dir.join(&candidate).exists() {
2356            return candidate;
2357        }
2358        i += 1;
2359    }
2360}
2361
2362/// The first free `<name>` / `<name>-1` / `<name>-2` … directory name in
2363/// `dir` (mirrors `resolve_target_name` for the `<slug>-assets/` move).
2364fn resolve_asset_dir_name(dir: &Path, name: &str) -> String {
2365    let mut candidate = name.to_string();
2366    let mut i = 1u32;
2367    while dir.join(&candidate).exists() {
2368        candidate = format!("{name}-{i}");
2369        i += 1;
2370    }
2371    candidate
2372}
2373
2374/// Copy a file atomically into `dir` under `target` (temp + rename, NFR-004).
2375/// The temp file is cleaned up on failure and never left behind.
2376fn atomic_copy(dir: &Path, target: &Path, source: &Path) -> Result<()> {
2377    let pid = std::process::id();
2378    let nonce = nanos();
2379    let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
2380    let copy_result = (|| -> std::io::Result<()> {
2381        std::fs::copy(source, &tmp)?;
2382        let f = std::fs::File::open(&tmp)?;
2383        f.sync_all()?;
2384        Ok(())
2385    })();
2386    if let Err(e) = copy_result {
2387        let _ = std::fs::remove_file(&tmp);
2388        return Err(TixError::Io(e));
2389    }
2390    if let Err(e) = std::fs::rename(&tmp, target) {
2391        let _ = std::fs::remove_file(&tmp);
2392        return Err(TixError::Io(e));
2393    }
2394    Ok(())
2395}
2396
2397/// Whether an asset file name is an image by extension (the `![…]` link form).
2398fn is_image_name(name: &str) -> bool {
2399    matches!(
2400        name.rsplit('.')
2401            .next()
2402            .unwrap_or("")
2403            .to_ascii_lowercase()
2404            .as_str(),
2405        "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp"
2406    )
2407}
2408
2409fn atomic_write(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
2410    write_file(dir, target, data, Link::Overwrite)
2411}
2412
2413/// Write `data` to `target`, refusing to replace an existing file.
2414///
2415/// [`atomic_write`] uses `rename`, which replaces the target unconditionally and is
2416/// therefore the wrong tool for *creating* an issue: a name collision would destroy
2417/// the file already there. This writes the payload to a temp file, `fsync`s it, and
2418/// then creates `target` with `hard_link`, which is atomic and fails with
2419/// `AlreadyExists` if the name is taken. The temp file is removed either way.
2420///
2421/// A hard link is the portable way to get `O_EXCL`-style semantics on the final
2422/// path (it works on unix and Windows and needs no new dependency). The temp file
2423/// lives in the same directory, so it is always on the same filesystem and the link
2424/// cannot fail for that reason. If the link is refused for any other reason — a
2425/// filesystem without hard links, say — it falls back to `rename`, which keeps
2426/// atomicity but is no longer no-clobber, and the caller's own probe is the
2427/// remaining protection.
2428///
2429/// The directory `fsync` that follows the link is what makes the *name* durable, so
2430/// it is not skippable on the fallback path either.
2431fn write_file_new(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
2432    write_file(dir, target, data, Link::Exclusive)
2433}
2434
2435#[derive(Clone, Copy)]
2436enum Link {
2437    Overwrite,
2438    Exclusive,
2439}
2440
2441fn write_file(dir: &Path, target: &Path, data: &[u8], link: Link) -> Result<()> {
2442    let pid = std::process::id();
2443    let nonce = nanos();
2444    let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
2445    let write_result = (|| -> std::io::Result<()> {
2446        let mut f = std::fs::File::create(&tmp)?;
2447        use std::io::Write;
2448        f.write_all(data)?;
2449        f.sync_all()?;
2450        Ok(())
2451    })();
2452    if let Err(e) = write_result {
2453        let _ = std::fs::remove_file(&tmp);
2454        return Err(TixError::Io(e));
2455    }
2456    let link_result = match link {
2457        Link::Overwrite => std::fs::rename(&tmp, target),
2458        Link::Exclusive => match std::fs::hard_link(&tmp, target) {
2459            // The name was taken: the payload is already durable, so only the link
2460            // failed. `AlreadyExists` is the signal `write_new_issue_file` retries on,
2461            // so it is propagated rather than flattened into a generic I/O error.
2462            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
2463            // No hard links on this filesystem. Atomicity survives; the no-clobber
2464            // guarantee does not, and the caller's probe is what is left.
2465            Err(_) => std::fs::rename(&tmp, target),
2466            Ok(()) => {
2467                let _ = std::fs::remove_file(&tmp);
2468                Ok(())
2469            }
2470        },
2471    };
2472    if let Err(e) = link_result {
2473        let _ = std::fs::remove_file(&tmp);
2474        return Err(TixError::Io(e));
2475    }
2476    let _ = std::fs::File::open(dir).and_then(|f| f.sync_all());
2477    Ok(())
2478}
2479
2480fn nanos() -> u64 {
2481    SystemTime::now()
2482        .duration_since(UNIX_EPOCH)
2483        .map(|d| d.as_nanos() as u64)
2484        .unwrap_or(0)
2485}
2486
2487#[cfg(test)]
2488mod tests {
2489    use super::*;
2490    // Only the tests need this; the library path went through `render_diagnostics`.
2491    use crate::model::{Priority, today};
2492    use crate::relationships::DiagnosticKind;
2493    use std::fs;
2494    use tempfile::TempDir;
2495
2496    /// The no-clobber layer, tested directly.
2497    ///
2498    /// `write_new_issue_file`'s **probe** is the common path and is covered end to
2499    /// end by `two_creates_with_the_same_title_both_survive` in `tests/cli.rs`.
2500    /// The probe cannot see a name another process takes between the probe and the
2501    /// write, which is why the final create is exclusive. This is the guarantee that
2502    /// closes that window, so it is asserted on the primitive rather than through a
2503    /// race that would be flaky to provoke.
2504    ///
2505    /// If the exclusive path were replaced by a `rename` — the obvious "simplify
2506    /// this" edit — the planted file below would be silently destroyed, which is
2507    /// precisely the data loss `write_new_issue_file` exists to prevent.
2508    #[test]
2509    fn write_file_new_refuses_an_existing_target() {
2510        let dir = TempDir::new().unwrap();
2511        let target = dir.path().join("issue.md");
2512        write_file_new(dir.path(), &target, b"first").unwrap();
2513        assert_eq!(fs::read_to_string(&target).unwrap(), "first");
2514
2515        let err = write_file_new(dir.path(), &target, b"second").unwrap_err();
2516        let TixError::Io(io_err) = err else {
2517            panic!("expected an I/O error, so the caller can retry on a name collision")
2518        };
2519        assert_eq!(
2520            io_err.kind(),
2521            std::io::ErrorKind::AlreadyExists,
2522            "the error must be AlreadyExists, so the caller can retry on a name \\
2523             collision rather than treating it as a hard failure"
2524        );
2525        assert_eq!(
2526            fs::read_to_string(&target).unwrap(),
2527            "first",
2528            "the existing file must be byte-identical — not truncated, not replaced"
2529        );
2530    }
2531
2532    /// The retry loop turns an `AlreadyExists` into the next free name.
2533    #[test]
2534    fn a_clobbered_name_is_retried_rather_than_failing_or_overwriting() {
2535        let tmp = TempDir::new().unwrap();
2536        let dir = tmp.path();
2537        let store = Store::open(&crate::config::Config {
2538            issues_dir: dir.to_path_buf(),
2539            ..Default::default()
2540        })
2541        .unwrap();
2542
2543        // The probe looks at `<issues_dir>/<segment>/<stem>.md`, so the plants have
2544        // to sit in the segment directory — that is where the collision is.
2545        let segment_dir = dir.join("seg");
2546        fs::create_dir_all(&segment_dir).unwrap();
2547
2548        // Two plants on the exact names the probe would try first, so the retry has
2549        // to walk past both to reach the third.
2550        for name in ["2026-01-01-race.md", "2026-01-01-race-2.md"] {
2551            fs::write(segment_dir.join(name), "planted").unwrap();
2552        }
2553
2554        let (filename, path) = store
2555            .write_new_issue_file("seg", "2026-01-01-race", b"mine")
2556            .unwrap();
2557        assert_eq!(filename, "seg/2026-01-01-race-3.md");
2558        assert_eq!(fs::read_to_string(&path).unwrap(), "mine");
2559        assert_eq!(
2560            fs::read_to_string(segment_dir.join("2026-01-01-race.md")).unwrap(),
2561            "planted"
2562        );
2563        assert_eq!(
2564            fs::read_to_string(segment_dir.join("2026-01-01-race-2.md")).unwrap(),
2565            "planted"
2566        );
2567    }
2568
2569    /// No temp files are left behind on either the success or the collision path.
2570    ///
2571    /// A leftover `.tix-tmp-*` in `docs/issues/<segment>/` would be picked up by the
2572    /// next `tix list` as a malformed issue, and the exclusive path has an extra
2573    /// step (`hard_link` then `remove_file`) where a leak is easy to introduce.
2574    #[test]
2575    fn no_temp_files_are_left_behind_on_success_or_collision() {
2576        let dir = TempDir::new().unwrap();
2577        let target = dir.path().join("issue.md");
2578        write_file_new(dir.path(), &target, b"first").unwrap();
2579        let _ = write_file_new(dir.path(), &target, b"second");
2580        let leftovers: Vec<String> = fs::read_dir(dir.path())
2581            .unwrap()
2582            .filter_map(|e| e.ok())
2583            .map(|e| e.file_name().to_string_lossy().to_string())
2584            .filter(|n| n.starts_with(".tix-tmp-"))
2585            .collect();
2586        assert!(
2587            leftovers.is_empty(),
2588            "temp files must not survive, found {leftovers:?}"
2589        );
2590    }
2591
2592    fn make_store() -> (tempfile::TempDir, Store) {
2593        let tmp = tempfile::TempDir::new().unwrap();
2594        let dir = tmp.path().join("issues");
2595        fs::create_dir_all(&dir).unwrap();
2596        let config_path = tmp.path().join("tix.toml");
2597        let config = Config {
2598            issues_dir: dir.clone(),
2599            ..Config::default()
2600        };
2601        config.save(&config_path).unwrap();
2602        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
2603        let store = Store {
2604            issues_dir: dir,
2605            config,
2606        };
2607        (tmp, store)
2608    }
2609
2610    fn write_issue(store: &Store, name: &str, contents: &str) {
2611        fs::write(store.issues_dir.join(name), contents).unwrap();
2612    }
2613
2614    fn sample_frontmatter(number: u32, title: &str, status: &str) -> String {
2615        format!(
2616            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
2617        )
2618    }
2619
2620    // ------------------------------------------------------------------
2621    // Issue #1492 — archived issues (dot-prefixed filename, in place)
2622    // ------------------------------------------------------------------
2623
2624    /// The default walk must skip a dot-prefixed file **without opening it**, so
2625    /// an archived file with unparseable frontmatter cannot break `list()`. That
2626    /// an unparseable one is *only* reachable through `list_all()` is the cheapest
2627    /// honest proof the skip happens before the read.
2628    #[test]
2629    fn list_never_opens_an_archived_file_but_list_all_does() {
2630        let (tmp, store) = make_store();
2631        let _ = tmp;
2632        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2633        write_issue(
2634            &store,
2635            "alice/2026-01-01-active.md",
2636            &sample_frontmatter(1, "Active", "todo"),
2637        );
2638        fs::write(
2639            store.issues_dir.join("alice/.2026-01-02-broken.md"),
2640            "this file has no frontmatter at all\n",
2641        )
2642        .unwrap();
2643
2644        let active = store
2645            .list()
2646            .expect("the default walk must not read the archived file");
2647        assert_eq!(active.len(), 1);
2648        assert_eq!(active[0].number, 1);
2649        assert!(!active[0].archived);
2650
2651        let all = store.list_all();
2652        assert!(
2653            matches!(all, Err(TixError::Parse(_))),
2654            "list_all() reads it, so the broken file is only invisible to list(): {all:?}"
2655        );
2656    }
2657
2658    #[test]
2659    fn archive_renames_in_place_and_restore_is_the_inverse() {
2660        let (tmp, store) = make_store();
2661        let _ = tmp;
2662        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2663        write_issue(
2664            &store,
2665            "alice/2026-01-01-x.md",
2666            &sample_frontmatter(5, "X", "todo"),
2667        );
2668
2669        let archived = store.archive("5").unwrap();
2670        assert_eq!(archived.filename, "alice/.2026-01-01-x.md");
2671        assert!(archived.archived);
2672        assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
2673        assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
2674        assert!(
2675            store.list().unwrap().is_empty(),
2676            "hidden from the default view"
2677        );
2678
2679        let all = store.list_all().unwrap();
2680        assert_eq!(all.len(), 1);
2681        assert!(all[0].archived);
2682        assert_eq!(all[0].number, 5, "the number is untouched");
2683
2684        // Both selectors still resolve the archived issue.
2685        assert_eq!(
2686            store.get(IssueSelector::Number(5)).unwrap().filename,
2687            "alice/.2026-01-01-x.md"
2688        );
2689        assert!(store.get(IssueSelector::Slug(slugify("X"))).is_ok());
2690
2691        let restored = store.restore("5").unwrap();
2692        assert_eq!(restored.filename, "alice/2026-01-01-x.md");
2693        assert!(!restored.archived);
2694        assert_eq!(store.list().unwrap().len(), 1, "visible again");
2695    }
2696
2697    #[test]
2698    fn archive_appends_one_event_and_leaves_the_frontmatter_untouched() {
2699        let (tmp, store) = make_store();
2700        let _ = tmp;
2701        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2702        write_issue(
2703            &store,
2704            "alice/2026-01-01-x.md",
2705            &sample_frontmatter(5, "X", "todo"),
2706        );
2707        let before = store
2708            .get(IssueSelector::Filename("alice/2026-01-01-x.md".into()))
2709            .unwrap();
2710
2711        let archived = store.archive("5").unwrap();
2712        assert_eq!(archived.history.len(), 1, "exactly one History event");
2713        assert_eq!(
2714            archived.history.events()[0].kind,
2715            HistoryEventKind::Archived
2716        );
2717
2718        let after = store.get(IssueSelector::Number(5)).unwrap();
2719        // Every field a reader cares about survives unchanged. `update` re-renders
2720        // the frontmatter (quoting and key order normalize, as on every tix write),
2721        // so the invariant is the *values*, not the bytes.
2722        assert_eq!(after.number, before.number);
2723        assert_eq!(after.title, before.title);
2724        assert_eq!(after.status, before.status);
2725        assert_eq!(after.labels, before.labels);
2726        assert_eq!(after.priority, before.priority);
2727        assert_eq!(after.assignee, before.assignee);
2728        assert_eq!(after.created, before.created);
2729        assert_eq!(
2730            after.updated, before.updated,
2731            "archiving must not bump `updated`: it is a frontmatter field"
2732        );
2733        // The marker is the filename; nothing was written into the frontmatter.
2734        let raw = read_issue_file(&store, "alice/.2026-01-01-x.md");
2735        let fm = raw.split("\n---").next().unwrap();
2736        assert!(
2737            !fm.contains("archived"),
2738            "the archived flag must never reach the frontmatter: {fm}"
2739        );
2740    }
2741
2742    #[test]
2743    fn restore_appends_one_restored_event() {
2744        let (tmp, store) = make_store();
2745        let _ = tmp;
2746        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2747        write_issue(
2748            &store,
2749            "alice/2026-01-01-x.md",
2750            &sample_frontmatter(5, "X", "todo"),
2751        );
2752        store.archive("5").unwrap();
2753        let restored = store.restore("5").unwrap();
2754        assert_eq!(restored.history.len(), 2, "archive + restore");
2755        assert_eq!(
2756            restored.history.events()[1].kind,
2757            HistoryEventKind::Restored
2758        );
2759    }
2760
2761    #[test]
2762    fn archiving_the_highest_number_does_not_free_it_for_reuse() {
2763        let (tmp, store) = make_store();
2764        let _ = tmp;
2765        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2766        write_issue(
2767            &store,
2768            "alice/2026-01-01-a.md",
2769            &sample_frontmatter(1, "A", "todo"),
2770        );
2771        write_issue(
2772            &store,
2773            "alice/2026-01-01-b.md",
2774            &sample_frontmatter(7, "B", "todo"),
2775        );
2776
2777        store.archive("7").unwrap();
2778
2779        let mut fresh = Issue::parse(&sample_frontmatter(0, "New", "todo")).unwrap();
2780        store.create(&mut fresh, "body", None).unwrap();
2781        assert_eq!(
2782            fresh.number, 8,
2783            "an archived issue keeps its number: `create` must not reuse #7"
2784        );
2785    }
2786
2787    #[test]
2788    fn redundant_archive_and_restore_are_refused_by_name() {
2789        let (tmp, store) = make_store();
2790        let _ = tmp;
2791        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2792        write_issue(
2793            &store,
2794            "alice/2026-01-01-x.md",
2795            &sample_frontmatter(5, "X", "todo"),
2796        );
2797
2798        let err = store.restore("5").unwrap_err();
2799        assert!(
2800            err.to_string().contains("not_archived"),
2801            "an active issue cannot be restored: {err}"
2802        );
2803        assert!(
2804            store.issues_dir.join("alice/2026-01-01-x.md").is_file(),
2805            "the refused restore writes nothing"
2806        );
2807
2808        store.archive("5").unwrap();
2809        let err = store.archive("5").unwrap_err();
2810        assert!(
2811            err.to_string().contains("already_archived"),
2812            "an archived issue cannot be re-archived: {err}"
2813        );
2814        assert!(
2815            store.issues_dir.join("alice/.2026-01-01-x.md").is_file(),
2816            "the refused archive writes nothing"
2817        );
2818    }
2819
2820    #[test]
2821    fn archive_never_clobbers_an_occupied_target_name() {
2822        let (tmp, store) = make_store();
2823        let _ = tmp;
2824        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2825        write_issue(
2826            &store,
2827            "alice/2026-01-01-x.md",
2828            &sample_frontmatter(5, "X", "todo"),
2829        );
2830        // A pre-existing dot-prefixed file would be the target of the rename.
2831        write_issue(
2832            &store,
2833            "alice/.2026-01-01-x.md",
2834            &sample_frontmatter(6, "Other", "todo"),
2835        );
2836        let err = store.archive("5").unwrap_err();
2837        assert!(err.to_string().contains("already exists"), "got: {err}");
2838        assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
2839        assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
2840    }
2841
2842    #[test]
2843    fn archive_and_restore_a_legacy_flat_file_in_place() {
2844        let (tmp, store) = make_store();
2845        let _ = tmp;
2846        write_issue(
2847            &store,
2848            "2026-01-01-legacy.md",
2849            &sample_frontmatter(3, "Legacy", "todo"),
2850        );
2851
2852        let archived = store.archive("3").unwrap();
2853        assert_eq!(archived.filename, ".2026-01-01-legacy.md");
2854        assert!(store.issues_dir.join(".2026-01-01-legacy.md").is_file());
2855        assert!(store.list().unwrap().is_empty());
2856        assert_eq!(store.list_all().unwrap().len(), 1);
2857
2858        let restored = store.restore("3").unwrap();
2859        assert_eq!(restored.filename, "2026-01-01-legacy.md");
2860        assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
2861    }
2862
2863    #[test]
2864    fn list_returns_sorted_by_filename() {
2865        let (tmp, store) = make_store();
2866        let _ = tmp;
2867        write_issue(
2868            &store,
2869            "2026-03-01-c.md",
2870            &sample_frontmatter(3, "C", "todo"),
2871        );
2872        write_issue(
2873            &store,
2874            "2026-01-01-a.md",
2875            &sample_frontmatter(1, "A", "todo"),
2876        );
2877        write_issue(
2878            &store,
2879            "2026-02-01-b.md",
2880            &sample_frontmatter(2, "B", "todo"),
2881        );
2882        let issues = store.list().unwrap();
2883        assert_eq!(issues.len(), 3);
2884        assert_eq!(issues[0].number, 1);
2885        assert_eq!(issues[1].number, 2);
2886        assert_eq!(issues[2].number, 3);
2887        assert_eq!(issues[0].title, "A");
2888    }
2889
2890    // Issue #10 follow-up — the single-file reader behind `Corpus::apply`.
2891
2892    #[test]
2893    fn is_issue_rel_path_matches_what_scan_walks() {
2894        // `scan` indexes these.
2895        for path in ["a.md", "alice/b.md", ".hidden.md", "alice/.hidden.md"] {
2896            assert!(
2897                is_issue_rel_path(Path::new(path)),
2898                "{path} is an issue file `scan` reads"
2899            );
2900        }
2901        // ...and skips these.
2902        for path in [
2903            "notes.txt",
2904            "alice/notes.txt",
2905            "alice",
2906            ".tix-tmp-x/a.md",
2907            "foo-assets/a.md",
2908            "deep/a/b.md",
2909            "alice/foo-assets/a.md",
2910        ] {
2911            assert!(
2912                !is_issue_rel_path(Path::new(path)),
2913                "{path} is not a path `scan` indexes"
2914            );
2915        }
2916    }
2917
2918    #[test]
2919    fn read_issue_at_sets_filename_and_archived_like_scan() {
2920        let (tmp, store) = make_store();
2921        let _ = tmp;
2922        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2923        write_issue(
2924            &store,
2925            "alice/2026-01-01-a.md",
2926            &sample_frontmatter(1, "A", "todo"),
2927        );
2928        write_issue(
2929            &store,
2930            "alice/.2026-01-02-b.md",
2931            &sample_frontmatter(2, "B", "todo"),
2932        );
2933
2934        let active = store
2935            .read_issue_at(Path::new("alice/2026-01-01-a.md"))
2936            .unwrap();
2937        assert_eq!(active.number, 1);
2938        assert_eq!(active.filename, "alice/2026-01-01-a.md");
2939        assert!(!active.archived);
2940
2941        let archived = store
2942            .read_issue_at(Path::new("alice/.2026-01-02-b.md"))
2943            .unwrap();
2944        assert_eq!(archived.filename, "alice/.2026-01-02-b.md");
2945        assert!(
2946            archived.archived,
2947            "the leading dot marks it archived, as `scan` does"
2948        );
2949
2950        // A flat file keeps its bare basename, and a missing one is `NotFound`.
2951        write_issue(&store, "flat.md", &sample_frontmatter(3, "F", "todo"));
2952        assert_eq!(
2953            store.read_issue_at(Path::new("flat.md")).unwrap().filename,
2954            "flat.md"
2955        );
2956        assert!(matches!(
2957            store.read_issue_at(Path::new("alice/gone.md")),
2958            Err(TixError::NotFound(_))
2959        ));
2960    }
2961
2962    #[test]
2963    fn list_ignores_non_md_files() {
2964        let (tmp, store) = make_store();
2965        let _ = tmp;
2966        write_issue(
2967            &store,
2968            "2026-01-01-a.md",
2969            &sample_frontmatter(1, "A", "todo"),
2970        );
2971        write_issue(&store, "readme.txt", "not an issue");
2972        write_issue(&store, "2026-01-01-sub", "no extension");
2973        let issues = store.list().unwrap();
2974        assert_eq!(issues.len(), 1);
2975    }
2976
2977    #[test]
2978    fn list_errors_on_malformed_frontmatter() {
2979        let (tmp, store) = make_store();
2980        let _ = tmp;
2981        write_issue(&store, "2026-01-01-bad.md", "no frontmatter here\n");
2982        let err = store.list().unwrap_err();
2983        assert!(matches!(err, TixError::Parse(_)), "got {err:?}");
2984    }
2985
2986    #[test]
2987    fn get_by_number() {
2988        let (tmp, store) = make_store();
2989        let _ = tmp;
2990        write_issue(
2991            &store,
2992            "2026-01-01-x.md",
2993            &sample_frontmatter(5, "X", "todo"),
2994        );
2995        let issue = store.get(IssueSelector::Number(5)).unwrap();
2996        assert_eq!(issue.title, "X");
2997    }
2998
2999    #[test]
3000    fn get_by_filename() {
3001        let (tmp, store) = make_store();
3002        let _ = tmp;
3003        write_issue(
3004            &store,
3005            "2026-01-01-y.md",
3006            &sample_frontmatter(2, "Y", "done"),
3007        );
3008        let issue = store
3009            .get(IssueSelector::Filename("2026-01-01-y.md".into()))
3010            .unwrap();
3011        assert_eq!(issue.status.display(), "done");
3012    }
3013
3014    #[test]
3015    fn get_by_slug() {
3016        let (tmp, store) = make_store();
3017        let _ = tmp;
3018        write_issue(
3019            &store,
3020            "2026-01-01-z.md",
3021            &sample_frontmatter(3, "Z", "todo"),
3022        );
3023        let issue = store.get(IssueSelector::Slug("z".into())).unwrap();
3024        assert_eq!(issue.number, 3);
3025    }
3026
3027    #[test]
3028    fn get_not_found() {
3029        let (tmp, store) = make_store();
3030        let _ = tmp;
3031        assert!(store.get(IssueSelector::Number(99)).is_err());
3032    }
3033
3034    #[test]
3035    fn create_assigns_number_from_live_issues_and_leaves_config_untouched() {
3036        let (tmp, store) = make_store();
3037        let _ = tmp;
3038        assert_eq!(store.config.state.next_number, 0);
3039        let mut issue = Issue {
3040            number: 0,
3041            title: "First".into(),
3042            status: Status::default(),
3043            labels: vec![],
3044            priority: Priority::default(),
3045            assignee: None,
3046            estimate: None,
3047            commit: None,
3048            gitlab_id: None,
3049            linear_id: None,
3050            mission: None,
3051            task: None,
3052            relationships: vec![],
3053            files: vec![],
3054            cycles: None,
3055            milestones: None,
3056            reviewer: None,
3057            start_date: None,
3058            due_date: None,
3059            created: "2026-01-01".into(),
3060            updated: "2026-01-01".into(),
3061            filename: String::new(),
3062            body: String::new(),
3063            history: crate::model::History::new(),
3064            has_retired_links_key: false,
3065            parent: None,
3066            extra_frontmatter: std::collections::BTreeMap::new(),
3067            archived: false,
3068            entered_at: None,
3069            source: None,
3070        };
3071        let path = store.create(&mut issue, "Hello world", None).unwrap();
3072        assert!(path.exists());
3073        assert_eq!(
3074            issue.number, 1,
3075            "an empty store starts at 1, not 0 — #0 is indistinguishable from a \
3076             sentinel or a failed read (tix #1376)"
3077        );
3078        // Issue #52: create never rewrites tix.toml with a counter.
3079        let config_path = tmp.path().join("tix.toml");
3080        let on_disk = std::fs::read_to_string(&config_path).unwrap();
3081        assert!(
3082            !on_disk.contains("next_number"),
3083            "create must not persist an issue-number counter:\n{on_disk}"
3084        );
3085        let reloaded = Config::load_with_warning(&config_path, |_| {}).unwrap();
3086        assert_eq!(reloaded.state.next_number, 0, "state is never written");
3087    }
3088
3089    #[test]
3090    fn create_resolves_max_live_number_plus_one() {
3091        let (tmp, store) = make_store();
3092        let _ = tmp;
3093        write_issue(
3094            &store,
3095            "2026-01-01-a.md",
3096            &sample_frontmatter(0, "A", "todo"),
3097        );
3098        write_issue(
3099            &store,
3100            "2026-01-01-b.md",
3101            &sample_frontmatter(1, "B", "todo"),
3102        );
3103        write_issue(
3104            &store,
3105            "2026-01-01-c.md",
3106            &sample_frontmatter(2, "C", "todo"),
3107        );
3108        let mut issue = Issue {
3109            number: 0,
3110            title: "D".into(),
3111            status: Status::default(),
3112            labels: vec![],
3113            priority: Priority::default(),
3114            assignee: None,
3115            estimate: None,
3116            commit: None,
3117            gitlab_id: None,
3118            linear_id: None,
3119            mission: None,
3120            task: None,
3121            relationships: vec![],
3122            files: vec![],
3123            cycles: None,
3124            milestones: None,
3125            reviewer: None,
3126            start_date: None,
3127            due_date: None,
3128            created: "2026-01-01".into(),
3129            updated: "2026-01-01".into(),
3130            filename: String::new(),
3131            body: String::new(),
3132            history: crate::model::History::new(),
3133            has_retired_links_key: false,
3134            parent: None,
3135            extra_frontmatter: std::collections::BTreeMap::new(),
3136            archived: false,
3137            entered_at: None,
3138            source: None,
3139        };
3140        store.create(&mut issue, "", None).unwrap();
3141        assert_eq!(issue.number, 3);
3142    }
3143
3144    #[test]
3145    fn create_reuses_number_of_deleted_highest_issue() {
3146        // Issue #52 tradeoff: without a persisted counter the next number is
3147        // re-derived from the live set, so deleting the highest-numbered
3148        // issue lets its number be reused by the next create.
3149        let (_tmp, store) = make_store();
3150        let mut issue_a = Issue {
3151            number: 0,
3152            title: "A".into(),
3153            status: Status::default(),
3154            labels: vec![],
3155            priority: Priority::default(),
3156            assignee: None,
3157            estimate: None,
3158            commit: None,
3159            gitlab_id: None,
3160            linear_id: None,
3161            mission: None,
3162            task: None,
3163            relationships: vec![],
3164            files: vec![],
3165            cycles: None,
3166            milestones: None,
3167            reviewer: None,
3168            start_date: None,
3169            due_date: None,
3170            created: "2026-01-01".into(),
3171            updated: "2026-01-01".into(),
3172            filename: String::new(),
3173            body: String::new(),
3174            history: crate::model::History::new(),
3175            has_retired_links_key: false,
3176            parent: None,
3177            extra_frontmatter: std::collections::BTreeMap::new(),
3178            archived: false,
3179            entered_at: None,
3180            source: None,
3181        };
3182        store.create(&mut issue_a, "", None).unwrap();
3183        let filename = issue_a.filename.clone();
3184        assert_eq!(issue_a.number, 1, "the first issue in a fresh store is #1");
3185        store.delete(&filename).unwrap();
3186        let mut issue_b = Issue {
3187            number: 0,
3188            title: "B".into(),
3189            status: Status::default(),
3190            labels: vec![],
3191            priority: Priority::default(),
3192            assignee: None,
3193            estimate: None,
3194            commit: None,
3195            gitlab_id: None,
3196            linear_id: None,
3197            mission: None,
3198            task: None,
3199            relationships: vec![],
3200            files: vec![],
3201            cycles: None,
3202            milestones: None,
3203            reviewer: None,
3204            start_date: None,
3205            due_date: None,
3206            created: "2026-01-02".into(),
3207            updated: "2026-01-02".into(),
3208            filename: String::new(),
3209            body: String::new(),
3210            history: crate::model::History::new(),
3211            has_retired_links_key: false,
3212            parent: None,
3213            extra_frontmatter: std::collections::BTreeMap::new(),
3214            archived: false,
3215            entered_at: None,
3216            source: None,
3217        };
3218        store.create(&mut issue_b, "", None).unwrap();
3219        assert_eq!(
3220            issue_b.number, 1,
3221            "an emptied store resumes at the same floor as a fresh one: 1"
3222        );
3223    }
3224
3225    #[test]
3226    fn create_with_custom_filename_stem() {
3227        let (tmp, store) = make_store();
3228        let _ = tmp;
3229        let mut issue = Issue {
3230            number: 0,
3231            title: "T".into(),
3232            status: Status::default(),
3233            labels: vec![],
3234            priority: Priority::default(),
3235            assignee: None,
3236            estimate: None,
3237            commit: None,
3238            gitlab_id: None,
3239            linear_id: None,
3240            mission: None,
3241            task: None,
3242            relationships: vec![],
3243            files: vec![],
3244            cycles: None,
3245            milestones: None,
3246            reviewer: None,
3247            start_date: None,
3248            due_date: None,
3249            created: "2026-01-01".into(),
3250            updated: "2026-01-01".into(),
3251            filename: String::new(),
3252            body: String::new(),
3253            history: crate::model::History::new(),
3254            has_retired_links_key: false,
3255            parent: None,
3256            extra_frontmatter: std::collections::BTreeMap::new(),
3257            archived: false,
3258            entered_at: None,
3259            source: None,
3260        };
3261        let path = store
3262            .create(&mut issue, "body", Some("custom-stem"))
3263            .unwrap();
3264        assert!(path.to_string_lossy().contains("custom-stem.md"));
3265    }
3266
3267    #[test]
3268    fn create_ensures_issues_dir_exists() {
3269        let tmp = tempfile::TempDir::new().unwrap();
3270        let dir = tmp.path().join("nested").join("issues");
3271        let config_path = tmp.path().join("tix.toml");
3272        let config = Config {
3273            issues_dir: dir.clone(),
3274            ..Config::default()
3275        };
3276        config.save(&config_path).unwrap();
3277        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
3278        let store = Store {
3279            issues_dir: dir,
3280            config,
3281        };
3282        let mut issue = Issue {
3283            number: 0,
3284            title: "T".into(),
3285            status: Status::default(),
3286            labels: vec![],
3287            priority: Priority::default(),
3288            assignee: None,
3289            estimate: None,
3290            commit: None,
3291            gitlab_id: None,
3292            linear_id: None,
3293            mission: None,
3294            task: None,
3295            relationships: vec![],
3296            files: vec![],
3297            cycles: None,
3298            milestones: None,
3299            reviewer: None,
3300            start_date: None,
3301            due_date: None,
3302            created: "2026-01-01".into(),
3303            updated: "2026-01-01".into(),
3304            filename: String::new(),
3305            body: String::new(),
3306            history: crate::model::History::new(),
3307            has_retired_links_key: false,
3308            parent: None,
3309            extra_frontmatter: std::collections::BTreeMap::new(),
3310            archived: false,
3311            entered_at: None,
3312            source: None,
3313        };
3314        store.create(&mut issue, "", None).unwrap();
3315        assert_eq!(issue.number, 1, "the first issue in a fresh store is #1");
3316    }
3317
3318    #[test]
3319    fn update_replaces_content_atomically() {
3320        let (tmp, store) = make_store();
3321        let _ = tmp;
3322        write_issue(
3323            &store,
3324            "2026-01-01-u.md",
3325            &sample_frontmatter(1, "U", "todo"),
3326        );
3327        let mut issue = store
3328            .get(IssueSelector::Filename("2026-01-01-u.md".into()))
3329            .unwrap();
3330        issue.title = "Updated".into();
3331        issue.bump_updated();
3332        store.update("2026-01-01-u.md", &issue).unwrap();
3333        let reloaded = store
3334            .get(IssueSelector::Filename("2026-01-01-u.md".into()))
3335            .unwrap();
3336        assert_eq!(reloaded.title, "Updated");
3337    }
3338
3339    #[test]
3340    fn update_not_found() {
3341        let (tmp, store) = make_store();
3342        let _ = tmp;
3343        let issue = Issue {
3344            number: 1,
3345            title: "T".into(),
3346            status: Status::default(),
3347            labels: vec![],
3348            priority: Priority::default(),
3349            assignee: None,
3350            estimate: None,
3351            commit: None,
3352            gitlab_id: None,
3353            linear_id: None,
3354            mission: None,
3355            task: None,
3356            relationships: vec![],
3357            files: vec![],
3358            cycles: None,
3359            milestones: None,
3360            reviewer: None,
3361            start_date: None,
3362            due_date: None,
3363            created: "2026-01-01".into(),
3364            updated: "2026-01-01".into(),
3365            filename: String::new(),
3366            body: String::new(),
3367            history: crate::model::History::new(),
3368            has_retired_links_key: false,
3369            parent: None,
3370            extra_frontmatter: std::collections::BTreeMap::new(),
3371            archived: false,
3372            entered_at: None,
3373            source: None,
3374        };
3375        assert!(store.update("nope.md", &issue).is_err());
3376    }
3377
3378    #[test]
3379    fn delete_removes_file() {
3380        let (tmp, store) = make_store();
3381        let _ = tmp;
3382        write_issue(
3383            &store,
3384            "2026-01-01-d.md",
3385            &sample_frontmatter(1, "D", "todo"),
3386        );
3387        assert!(store.issues_dir.join("2026-01-01-d.md").exists());
3388        store.delete("2026-01-01-d.md").unwrap();
3389        assert!(!store.issues_dir.join("2026-01-01-d.md").exists());
3390    }
3391
3392    #[test]
3393    fn delete_not_found() {
3394        let (tmp, store) = make_store();
3395        let _ = tmp;
3396        assert!(store.delete("nope.md").is_err());
3397    }
3398
3399    #[test]
3400    fn stray_tmp_file_not_treated_as_issue() {
3401        let (tmp, store) = make_store();
3402        let _ = tmp;
3403        write_issue(
3404            &store,
3405            "2026-01-01-a.md",
3406            &sample_frontmatter(1, "A", "todo"),
3407        );
3408        fs::write(store.issues_dir.join(".tix-tmp-9999-0"), "orphaned data").unwrap();
3409        let issues = store.list().unwrap();
3410        assert_eq!(issues.len(), 1);
3411    }
3412
3413    // ------------------------------------------------------------------
3414    // WP03 T015 — the storage seam's contract, as executable evidence
3415    // ------------------------------------------------------------------
3416    //
3417    // Every case asserts the **file bytes**, not just the returned `Result`. A
3418    // refusal that returns an error after having already re-serialised the issue
3419    // would pass an `is_err()` assertion and still have destroyed the audit trail,
3420    // so "did it refuse?" and "did it touch anything?" are separate questions here
3421    // and both are asked.
3422
3423    /// A store whose board **declares a `category_map`**, so category policy —
3424    /// the terminal lock and adjacency — applies.
3425    ///
3426    /// The mapping is `todo=unstarted, doing=active, done=completed`, which is
3427    /// forward-moving in the declared vocabulary order. `triage` is added as a
3428    /// `backlog` column so there is a genuine backwards move to be refused.
3429    fn seam_store() -> (tempfile::TempDir, Store) {
3430        let tmp = tempfile::TempDir::new().unwrap();
3431        let dir = tmp.path().join("issues");
3432        fs::create_dir_all(&dir).unwrap();
3433        let config_path = tmp.path().join("tix.toml");
3434        let config = Config {
3435            issues_dir: dir.clone(),
3436            kanban: crate::config::KanbanSection {
3437                configurations: indexmap::IndexMap::from([(
3438                    "default".to_string(),
3439                    crate::config::BoardConfiguration {
3440                        columns: vec![
3441                            "triage".into(),
3442                            "todo".into(),
3443                            "doing".into(),
3444                            "done".into(),
3445                        ],
3446                        done_column: Some("done".to_string()),
3447                        category_map: indexmap::IndexMap::from([
3448                            ("triage".to_string(), "backlog".to_string()),
3449                            ("todo".to_string(), "unstarted".to_string()),
3450                            ("doing".to_string(), "active".to_string()),
3451                            ("done".to_string(), "completed".to_string()),
3452                        ]),
3453                        ..Default::default()
3454                    },
3455                )]),
3456            },
3457            ..Config::default()
3458        };
3459        config.save(&config_path).unwrap();
3460        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
3461        (
3462            tmp,
3463            Store {
3464                issues_dir: dir,
3465                config,
3466            },
3467        )
3468    }
3469
3470    /// A seam store holding one issue at `status`, and that file's bytes.
3471    ///
3472    /// The fixture carries every key the model always writes, `files` included.
3473    /// That is not tidiness: `t015_no_new_frontmatter_key_was_introduced` compares
3474    /// the key set before and after, and a fixture that omits `files` makes the
3475    /// comparison report the model's own always-written key as a change — so the
3476    /// test would have been measuring the fixture instead of the seam.
3477    fn seam_issue(status: &str) -> (tempfile::TempDir, Store, String) {
3478        let (tmp, store) = seam_store();
3479        write_issue(
3480            &store,
3481            "2026-01-01-seam.md",
3482            &format!(
3483                "---\nnumber: 1\ntitle: \"Seam\"\nstatus: {status}\nlabels: []\n\
3484                 priority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody."
3485            ),
3486        );
3487        let before = read_issue_file(&store, "2026-01-01-seam.md");
3488        (tmp, store, before)
3489    }
3490
3491    /// Assert a refusal left the file byte-identical.
3492    fn assert_untouched(store: &Store, before: &str, what: &str) {
3493        let after = read_issue_file(store, "2026-01-01-seam.md");
3494        assert_eq!(after, before, "{what}: the file must be byte-identical");
3495    }
3496
3497    #[test]
3498    fn t015_accepted_transition_writes_status_bumps_updated_and_appends_one_decision() {
3499        let (_tmp, store, _before) = seam_issue("todo");
3500        let outcome = store
3501            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3502            .unwrap();
3503        assert!(outcome.written, "an accepted, changing move writes");
3504        let decision = outcome
3505            .decision
3506            .clone()
3507            .expect("an accepted transition appends exactly one decision");
3508        assert_eq!(decision.from, "todo");
3509        assert_eq!(decision.to, "doing");
3510        assert_eq!(decision.from_category, "unstarted");
3511        assert_eq!(decision.to_category, "active");
3512        assert!(
3513            decision.override_reason.is_none(),
3514            "no override was supplied"
3515        );
3516        assert_eq!(
3517            outcome.issue.status.display(),
3518            "doing",
3519            "Issue.status holds the COLUMN name, never a category (C-001)"
3520        );
3521        assert_eq!(outcome.issue.updated, today(), "`updated` is bumped");
3522
3523        // Re-read from disk: the in-memory record is not evidence of what landed.
3524        let reloaded = store
3525            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3526            .unwrap();
3527        assert_eq!(reloaded.status.display(), "doing");
3528        let events = reloaded.history.events();
3529        assert_eq!(
3530            events.len(),
3531            1,
3532            "exactly one History entry, and it is the decision: {events:?}"
3533        );
3534        assert_eq!(events[0].kind, HistoryEventKind::TransitionAccepted);
3535        assert_eq!(
3536            crate::model::TransitionDecision::from_event(events[0])
3537                .unwrap()
3538                .to,
3539            "doing",
3540            "and the decision round-trips out of the file with its fields intact"
3541        );
3542    }
3543
3544    #[test]
3545    fn t015_unknown_column_is_refused_by_name_and_writes_nothing() {
3546        let (_tmp, store, before) = seam_issue("todo");
3547        let err = store
3548            .set_status("2026-01-01-seam.md", StatusChange::to("nope"))
3549            .unwrap_err();
3550        assert!(
3551            matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "unknown_column"),
3552            "the shared named diagnostic, contract §6: {err:?}"
3553        );
3554        assert!(
3555            err.to_string()
3556                .contains("valid columns: triage, todo, doing, done"),
3557            "in BOARD order, not hash order: {err}"
3558        );
3559        assert_untouched(&store, &before, "unknown column");
3560    }
3561
3562    #[test]
3563    fn t015_terminal_exit_is_refused_and_writes_nothing() {
3564        let (_tmp, store, before) = seam_issue("done");
3565        let err = store
3566            .set_status("2026-01-01-seam.md", StatusChange::to("todo"))
3567            .unwrap_err();
3568        assert!(
3569            matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
3570            "{err:?}"
3571        );
3572        assert_untouched(&store, &before, "terminal exit without a reason");
3573    }
3574
3575    #[test]
3576    fn t015_adjacency_restricted_backwards_move_is_refused_and_writes_nothing() {
3577        // `doing` is `active`; `triage` is `backlog`. Forward in the declared
3578        // vocabulary order, so no terminal lock applies — this is adjacency alone,
3579        // and it must name the same `invalid_transition` kind.
3580        let (_tmp, store, before) = seam_issue("doing");
3581        let err = store
3582            .set_status("2026-01-01-seam.md", StatusChange::to("triage"))
3583            .unwrap_err();
3584        assert!(
3585            matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
3586            "{err:?}"
3587        );
3588        assert!(
3589            err.to_string().contains("category policy"),
3590            "the message names the rule that refused it: {err}"
3591        );
3592        assert_untouched(&store, &before, "adjacency refusal");
3593    }
3594
3595    #[test]
3596    fn t015_terminal_exit_with_a_reason_is_accepted_and_the_reason_is_recorded() {
3597        let (_tmp, store, before) = seam_issue("done");
3598        let outcome = store
3599            .set_status(
3600                "2026-01-01-seam.md",
3601                StatusChange::to("todo").with_override(Some("reopening: wrong epic")),
3602            )
3603            .unwrap();
3604        assert!(outcome.written);
3605        let decision = outcome.decision.expect("one decision");
3606        assert_eq!(
3607            decision.override_reason.as_deref(),
3608            Some("reopening: wrong epic"),
3609            "an override with no recorded reason is not an audit record"
3610        );
3611        assert_ne!(
3612            read_issue_file(&store, "2026-01-01-seam.md"),
3613            before,
3614            "sanity: an accepted move must have changed the file at all"
3615        );
3616    }
3617
3618    #[test]
3619    fn t015_a_blank_reason_is_not_an_override() {
3620        // The service trims and drops a blank reason, so a whitespace-only string
3621        // must not unlock a terminal exit. Asserted here as well as in the service
3622        // because "the seam passes the caller's string through untouched" would
3623        // satisfy a service-level test and still be wrong at the boundary.
3624        let (_tmp, store, before) = seam_issue("done");
3625        let err = store
3626            .set_status(
3627                "2026-01-01-seam.md",
3628                StatusChange::to("todo").with_override(Some("   ")),
3629            )
3630            .unwrap_err();
3631        assert!(
3632            matches!(&err, TixError::Workflow(_)),
3633            "a blank reason is not a reason: {err:?}"
3634        );
3635        assert_untouched(&store, &before, "blank override reason");
3636    }
3637
3638    #[test]
3639    fn t015_same_column_is_accepted_and_writes_nothing() {
3640        let (_tmp, store, before) = seam_issue("doing");
3641        let outcome = store
3642            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3643            .unwrap();
3644        assert!(!outcome.written, "a same-column re-apply is a no-op");
3645        assert!(
3646            outcome.decision.is_none(),
3647            "and appends no decision, contract §3.2"
3648        );
3649        assert_untouched(&store, &before, "same column");
3650        // `updated` is not bumped either: a no-op that touched the date would make
3651        // `tix edit --status <current column>` look like an edit.
3652        let reloaded = store
3653            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3654            .unwrap();
3655        assert_eq!(
3656            reloaded.updated, "2026-01-01",
3657            "the fixture's original date"
3658        );
3659    }
3660
3661    #[test]
3662    fn t015_no_new_frontmatter_key_was_introduced() {
3663        // C-001. The mission must be byte-compatible on the file surface, so this
3664        // compares the key set of a file the seam wrote against the key set of the
3665        // fixture it read. A category key on the issue would break here.
3666        let (_tmp, store, before) = seam_issue("todo");
3667        store
3668            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3669            .unwrap();
3670        let after = read_issue_file(&store, "2026-01-01-seam.md");
3671        let keys = |text: &str| -> Vec<String> {
3672            text.split("---\n")
3673                .nth(1)
3674                .unwrap()
3675                .lines()
3676                .filter_map(|l| l.split_once(':').map(|(k, _)| k.trim().to_string()))
3677                .filter(|k| !k.is_empty())
3678                .collect()
3679        };
3680        assert_eq!(
3681            keys(&after),
3682            keys(&before),
3683            "the seam must not add or remove a top-level frontmatter key"
3684        );
3685    }
3686
3687    #[test]
3688    fn t015_a_hand_edited_inconsistent_file_is_read_and_not_repaired() {
3689        // C-005. A file whose `status` was hand-edited to a column the policy would
3690        // never have allowed is *readable* and this path must leave it alone. The
3691        // diagnosis is WP06's job; storage correcting it would be the escalation
3692        // the decision `01M3C3XBMTR5WFV7059NXM04AJ` names.
3693        let (_tmp, store, before) = seam_issue("doing");
3694        // Simulate the hand edit: `doing` (active) becomes `triage` (backlog),
3695        // a move adjacency refuses.
3696        let edited = before.replace("status: doing", "status: triage");
3697        write_issue(&store, "2026-01-01-seam.md", &edited);
3698        let after_edit = read_issue_file(&store, "2026-01-01-seam.md");
3699
3700        let issue = store
3701            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3702            .unwrap();
3703        assert_eq!(
3704            issue.status.display(),
3705            "triage",
3706            "a hand-edited status is read, not rejected at load"
3707        );
3708        assert_eq!(
3709            read_issue_file(&store, "2026-01-01-seam.md"),
3710            after_edit,
3711            "reading must not repair it"
3712        );
3713        // And a legal move away from the hand-edited column proceeds, without the
3714        // seam trying to reconcile the history of how the file got there.
3715        store
3716            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3717            .unwrap();
3718        let moved = store
3719            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3720            .unwrap();
3721        assert_eq!(moved.status.display(), "doing");
3722        assert_eq!(
3723            moved.history.events().len(),
3724            1,
3725            "only the new decision; nothing was written to explain the old one"
3726        );
3727    }
3728
3729    #[test]
3730    fn t015_every_rejection_kind_is_one_of_the_contract_vocabulary() {
3731        // The contract fixes seven names (contract §6). A refusal that invents an
3732        // eighth is a surface that reports a condition under a name no other
3733        // surface knows, which is the exact failure the vocabulary exists to stop.
3734        let allowed = [
3735            "missing_completion_category",
3736            "unknown_category",
3737            "mapping_for_unknown_column",
3738            "unmapped_column_default",
3739            "invalid_transition",
3740            "unknown_column",
3741            "direct_edit_policy_violation",
3742        ];
3743        let cases: Vec<(&str, &str)> = vec![
3744            ("todo", "nope"),    // unknown_column
3745            ("done", "todo"),    // invalid_transition (terminal lock)
3746            ("doing", "triage"), // invalid_transition (adjacency)
3747        ];
3748        for (from, to) in cases {
3749            let (_tmp, store, _before) = seam_issue(from);
3750            let err = store
3751                .set_status("2026-01-01-seam.md", StatusChange::to(to))
3752                .unwrap_err();
3753            let TixError::Workflow(d) = &err else {
3754                panic!("{from} -> {to} must refuse through the workflow variant: {err:?}");
3755            };
3756            assert!(
3757                allowed.contains(&d.kind.as_str()),
3758                "`{}` is not in the contract's vocabulary",
3759                d.kind.as_str()
3760            );
3761        }
3762    }
3763
3764    #[test]
3765    fn t015_a_legacy_board_gains_no_policy_but_keeps_completion() {
3766        // The opt-in this WP decided: a board with no `category_map` gets the
3767        // completion definition and the commit-clearing rule, and no transition
3768        // restriction. Both halves are asserted, because a change that dropped the
3769        // completion rule along with the policy would otherwise look correct.
3770        let (tmp, store) = make_store();
3771        let _ = tmp;
3772        write_issue(
3773            &store,
3774            "2026-01-01-legacy.md",
3775            &sample_frontmatter(1, "Legacy", "done"),
3776        );
3777        let moved = store
3778            .set_status("2026-01-01-legacy.md", StatusChange::to("todo"))
3779            .unwrap();
3780        assert!(moved.written, "a legacy board imposes no policy");
3781        assert_eq!(moved.issue.status.display(), "todo");
3782
3783        // And completion still resolves through the reconciliation, not the string
3784        // "done": this issue is no longer complete, and an untouched `done` one is.
3785        let resolution =
3786            crate::workflow::resolution(&store.config, store.config.default_config(), "default");
3787        assert!(
3788            !resolution.explicit_policy,
3789            "precondition: this board declared no category_map"
3790        );
3791        assert!(
3792            resolution.has_completion(),
3793            "but it still has a done column"
3794        );
3795        assert!(
3796            !crate::workflow::is_completed(&moved.issue, &resolution),
3797            "and `todo` is not complete"
3798        );
3799    }
3800
3801    #[test]
3802    fn t015_a_mapped_board_keeps_the_commit_coupling() {
3803        // The commit rule moved from `mv.rs` into the seam, so it is pinned here
3804        // rather than only in the command layer: reaching completion records the
3805        // commit, leaving it clears the one that was recorded.
3806        let (_tmp, store, _before) = seam_issue("doing");
3807        store
3808            .set_status(
3809                "2026-01-01-seam.md",
3810                StatusChange::to("done").with_commit(Some("abc123")),
3811            )
3812            .unwrap();
3813        let at_done = store
3814            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3815            .unwrap();
3816        assert_eq!(at_done.commit.as_deref(), Some("abc123"));
3817
3818        store
3819            .set_status(
3820                "2026-01-01-seam.md",
3821                StatusChange::to("todo").with_override(Some("not actually finished")),
3822            )
3823            .unwrap();
3824        let off_done = store
3825            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3826            .unwrap();
3827        assert_eq!(
3828            off_done.commit, None,
3829            "leaving completion clears the recorded commit"
3830        );
3831    }
3832
3833    #[test]
3834    fn t015_a_decision_round_trips_through_a_file_unchanged() {
3835        // T012's round trip, end to end rather than in memory: the rendered line
3836        // must parse back to the same fields, or WP06 cannot read a file's last
3837        // accepted column out of it.
3838        let (_tmp, store, _before) = seam_issue("todo");
3839        store
3840            .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3841            .unwrap();
3842        let first = store
3843            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3844            .unwrap();
3845        let text = read_issue_file(&store, "2026-01-01-seam.md");
3846        // Parse the file back from its own bytes — not from the in-memory issue.
3847        let parsed = Issue::parse(&text).unwrap();
3848        let events = parsed.history.events();
3849        let decision = crate::model::TransitionDecision::from_event(events[0]).unwrap();
3850        assert_eq!(decision.to, "doing");
3851        assert_eq!(decision.from_category, "unstarted");
3852        assert_eq!(decision.to_category, "active");
3853        // And re-serialising the parsed file reproduces it byte for byte, so the
3854        // grammar is not lossy in a way only a rewrite would reveal.
3855        assert_eq!(
3856            parsed.to_file_contents(),
3857            text,
3858            "a parsed decision must re-render to the same bytes"
3859        );
3860        assert_eq!(parsed.history.events().len(), first.history.events().len());
3861    }
3862
3863    #[test]
3864    fn t015_two_transitions_append_two_decisions_in_order() {
3865        let (_tmp, store, _before) = seam_issue("triage");
3866        for to in ["todo", "doing"] {
3867            store
3868                .set_status("2026-01-01-seam.md", StatusChange::to(to))
3869                .unwrap();
3870        }
3871        let after = store
3872            .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3873            .unwrap();
3874        let events = after.history.events();
3875        assert_eq!(events.len(), 2, "one per accepted transition");
3876        let tos: Vec<String> = events
3877            .iter()
3878            .map(|e| crate::model::TransitionDecision::from_event(e).unwrap().to)
3879            .collect();
3880        assert_eq!(tos, vec!["todo", "doing"], "append order, not sorted");
3881    }
3882
3883    #[test]
3884    fn set_status_changes_status_and_bumps_updated() {
3885        let (tmp, store) = make_store();
3886        let _ = tmp;
3887        write_issue(
3888            &store,
3889            "2026-01-01-s.md",
3890            &sample_frontmatter(1, "S", "todo"),
3891        );
3892        let outcome = store
3893            .set_status("2026-01-01-s.md", StatusChange::to("in progress"))
3894            .unwrap();
3895        let issue = &outcome.issue;
3896        assert_eq!(issue.status.display(), "in progress");
3897        assert_eq!(issue.updated, today());
3898        assert_eq!(issue.title, "S");
3899        assert_eq!(issue.body, "Body text.");
3900    }
3901
3902    #[test]
3903    fn set_status_preserves_file_path() {
3904        let (tmp, store) = make_store();
3905        let _ = tmp;
3906        write_issue(
3907            &store,
3908            "2026-01-01-f.md",
3909            &sample_frontmatter(1, "F", "todo"),
3910        );
3911        let _ = store
3912            .set_status("2026-01-01-f.md", StatusChange::to("done"))
3913            .unwrap();
3914        assert!(store.issues_dir.join("2026-01-01-f.md").exists());
3915    }
3916
3917    // ------------------------------------------------------------------
3918    // WP02 T009 — typed relationship writes delegate to the service
3919    // ------------------------------------------------------------------
3920
3921    /// Read an issue file's raw bytes, for the byte-identical assertions.
3922    fn read_issue_file(store: &Store, filename: &str) -> String {
3923        std::fs::read_to_string(store.issues_dir.join(filename)).unwrap()
3924    }
3925
3926    /// A frontmatter fixture with no relationship data, i.e. what every
3927    /// pre-existing issue file in the repository looks like.
3928    fn bare_frontmatter(number: u32, title: &str) -> String {
3929        format!(
3930            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
3931        )
3932    }
3933
3934    /// A frontmatter fixture carrying the **retired** `links:` key.
3935    fn legacy_links_frontmatter(number: u32, title: &str, links: &str) -> String {
3936        format!(
3937            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\nlinks: {links}\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
3938        )
3939    }
3940
3941    /// Two seeded issues, plus the store, for the relationship tests.
3942    fn related_pair() -> (TempDir, Store, Issue, Issue) {
3943        let (tmp, store) = make_store();
3944        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
3945        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
3946        let a = store.get(IssueSelector::Number(1)).unwrap();
3947        let b = store.get(IssueSelector::Number(2)).unwrap();
3948        (tmp, store, a, b)
3949    }
3950
3951    #[test]
3952    fn t009_link_records_a_single_directed_edge() {
3953        let (_tmp, store, a, b) = related_pair();
3954        let outcome = store
3955            .link(
3956                &a.number.to_string(),
3957                &b.number.to_string(),
3958                RelationKind::Blocks,
3959            )
3960            .unwrap();
3961        assert!(outcome.changed);
3962
3963        // One edge, stored once, on the blocking side. The old symmetric
3964        // back-reference on the other issue is gone (C-001).
3965        let a_after = store.get(IssueSelector::Number(1)).unwrap();
3966        let b_after = store.get(IssueSelector::Number(2)).unwrap();
3967        assert_eq!(a_after.relationships.len(), 1);
3968        assert!(
3969            b_after.relationships.is_empty(),
3970            "no back-reference is written for a directed edge"
3971        );
3972        assert_eq!(a_after.relationships[0].kind, RelationKind::Blocks);
3973        assert_eq!(
3974            (a_after.relationships[0].from, a_after.relationships[0].to),
3975            (1, 2)
3976        );
3977    }
3978
3979    #[test]
3980    fn t009_link_appends_exactly_one_history_event() {
3981        let (_tmp, store, a, b) = related_pair();
3982        store
3983            .link(
3984                &a.number.to_string(),
3985                &b.number.to_string(),
3986                RelationKind::Blocks,
3987            )
3988            .unwrap();
3989        let a_after = store.get(IssueSelector::Number(1)).unwrap();
3990        assert_eq!(a_after.history.len(), 1);
3991        assert_eq!(
3992            a_after.history.events()[0].kind,
3993            HistoryEventKind::RelationshipAdded
3994        );
3995        // Every event records an actor, or the audit trail is not an audit trail.
3996        assert!(!a_after.history.events()[0].actor.is_empty());
3997    }
3998
3999    #[test]
4000    fn t009_idempotent_reapply_writes_nothing_and_appends_no_second_event() {
4001        let (_tmp, store, a, b) = related_pair();
4002        let args = (a.number.to_string(), b.number.to_string());
4003        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4004        let first = store.get(IssueSelector::Number(1)).unwrap();
4005        let bytes_after_first = read_issue_file(&store, &first.filename);
4006
4007        let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4008        assert!(!second.changed, "re-applying an existing edge is a no-op");
4009        assert!(second.written.is_none(), "no file is written for a no-op");
4010
4011        let after = store.get(IssueSelector::Number(1)).unwrap();
4012        assert_eq!(after.relationships.len(), 1, "no duplicate edge");
4013        assert_eq!(after.history.len(), 1, "no second History event");
4014        assert_eq!(
4015            read_issue_file(&store, &after.filename),
4016            bytes_after_first,
4017            "the file is byte-identical after the no-op"
4018        );
4019    }
4020
4021    #[test]
4022    fn t009_inverse_spelling_resolves_to_the_same_stored_edge() {
4023        let (_tmp, store, a, b) = related_pair();
4024        let args = (a.number.to_string(), b.number.to_string());
4025        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4026        // "2 is blocked by 1" is the same edge as "1 blocks 2".
4027        let again = store
4028            .link(&args.1, &args.0, RelationKind::BlockedBy)
4029            .unwrap();
4030        assert!(!again.changed, "the inverse spelling must not add an edge");
4031        let after = store.get(IssueSelector::Number(1)).unwrap();
4032        assert_eq!(after.relationships.len(), 1);
4033        assert_eq!(
4034            after.relationships[0].kind,
4035            RelationKind::Blocks,
4036            "the stored kind is always the canonical one"
4037        );
4038    }
4039
4040    #[test]
4041    fn t009_self_link_is_refused_and_writes_nothing() {
4042        let (_tmp, store, a, _b) = related_pair();
4043        let before = read_issue_file(&store, &a.filename);
4044        let err = store
4045            .link(
4046                &a.number.to_string(),
4047                &a.number.to_string(),
4048                RelationKind::Blocks,
4049            )
4050            .unwrap_err();
4051        assert!(matches!(err, TixError::InvalidInput(_)));
4052        assert!(
4053            err.to_string().contains("self_link"),
4054            "the diagnostic names the rule: {err}"
4055        );
4056        assert_eq!(
4057            read_issue_file(&store, &a.filename),
4058            before,
4059            "SC-005: a rejected mutation leaves the file byte-identical"
4060        );
4061    }
4062
4063    #[test]
4064    fn t009_cycle_closing_edge_is_refused_and_writes_nothing() {
4065        let (tmp, store) = make_store();
4066        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
4067        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
4068        write_issue(&store, "2026-01-01-c.md", &bare_frontmatter(3, "C"));
4069        let _ = tmp;
4070        store.link("1", "2", RelationKind::Blocks).unwrap();
4071        store.link("2", "3", RelationKind::Blocks).unwrap();
4072        let c_before = read_issue_file(&store, "2026-01-01-c.md");
4073
4074        let err = store.link("3", "1", RelationKind::Blocks).unwrap_err();
4075        assert!(err.to_string().contains("cycle"), "{err}");
4076        assert_eq!(
4077            read_issue_file(&store, "2026-01-01-c.md"),
4078            c_before,
4079            "SC-005: both files of a rejected two-issue mutation are unchanged"
4080        );
4081    }
4082
4083    #[test]
4084    fn t009_unlink_removes_the_edge_and_is_idempotent() {
4085        let (_tmp, store, a, b) = related_pair();
4086        let args = (a.number.to_string(), b.number.to_string());
4087        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4088
4089        let removed = store
4090            .unlink(&args.0, &args.1, RelationKind::Blocks)
4091            .unwrap();
4092        assert!(removed.changed);
4093        let after = store.get(IssueSelector::Number(1)).unwrap();
4094        assert!(after.relationships.is_empty());
4095        assert_eq!(after.history.len(), 2, "added then removed");
4096
4097        // Removing again is a no-op, not a second History entry.
4098        let again = store
4099            .unlink(&args.0, &args.1, RelationKind::Blocks)
4100            .unwrap();
4101        assert!(!again.changed);
4102        let after2 = store.get(IssueSelector::Number(1)).unwrap();
4103        assert_eq!(after2.history.len(), 2, "no third History event");
4104    }
4105
4106    #[test]
4107    fn t009_unlink_of_a_never_linked_pair_is_a_noop() {
4108        let (_tmp, store, a, b) = related_pair();
4109        let outcome = store
4110            .unlink(
4111                &a.number.to_string(),
4112                &b.number.to_string(),
4113                RelationKind::Blocks,
4114            )
4115            .unwrap();
4116        assert!(!outcome.changed);
4117        assert!(outcome.written.is_none());
4118        let after = store.get(IssueSelector::Number(1)).unwrap();
4119        assert!(after.relationships.is_empty());
4120        assert!(after.history.is_empty(), "a no-op appends nothing");
4121    }
4122
4123    #[test]
4124    fn t009_set_relationships_replaces_the_set_and_keeps_surviving_ids() {
4125        let (_tmp, store, _a, _b) = related_pair();
4126        store.link("1", "2", RelationKind::Blocks).unwrap();
4127        let before = store.get(IssueSelector::Number(1)).unwrap();
4128        let survivor_id = before.relationships[0].id.clone();
4129
4130        // Replace the set with a different target; the old edge goes, recorded.
4131        let updated = store
4132            .set_relationships(&before.filename, &[(2, RelationKind::Blocks)])
4133            .unwrap();
4134        assert_eq!(updated.relationships.len(), 1);
4135        assert_eq!(
4136            updated.relationships[0].id, survivor_id,
4137            "a surviving relationship keeps its stable id"
4138        );
4139    }
4140
4141    #[test]
4142    fn t009_set_relationships_rejects_unknown_target_and_self() {
4143        let (_tmp, store, a, _b) = related_pair();
4144        assert!(
4145            store
4146                .set_relationships(&a.filename, &[(404, RelationKind::Blocks)])
4147                .is_err(),
4148            "an unknown target is refused"
4149        );
4150        assert!(
4151            store
4152                .set_relationships(&a.filename, &[(1, RelationKind::Blocks)])
4153                .is_err(),
4154            "a self-link is refused"
4155        );
4156    }
4157
4158    // ------------------------------------------------------------------
4159    // WP02 review round 1 — F1, F4, F5, F13: canonical storage lookups
4160    // ------------------------------------------------------------------
4161
4162    /// Seed an issue, optionally with raw extra frontmatter.
4163    fn seeded(store: &Store, number: u32, title: &str, extra: &str) {
4164        let slug = title.to_lowercase();
4165        write_issue(
4166            store,
4167            &format!("2026-01-01-{slug}.md"),
4168            &format!(
4169                "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\n{extra}files: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
4170            ),
4171        );
4172    }
4173
4174    #[test]
4175    fn f1_set_relationships_refuses_a_reciprocal_pair_the_service_rejects() {
4176        // F1. `set_relationships` used to re-implement existence and the
4177        // self-link rule locally and skip the rest, so with `1 blocks 2` on disk
4178        // it stored `2 blocks 1` — a reciprocal pair that `link` rejects one
4179        // line earlier and that contract §3 rules 3 and 4 require be refused.
4180        // Latent only because `--links` hard-codes `related`; user-reachable the
4181        // moment WP03 wires `--type`.
4182        let (_tmp, store) = make_store();
4183        seeded(&store, 1, "A", "");
4184        seeded(&store, 2, "B", "");
4185        store.link("1", "2", RelationKind::Blocks).unwrap();
4186
4187        let err = store
4188            .set_relationships("2026-01-01-b.md", &[(1, RelationKind::Blocks)])
4189            .unwrap_err();
4190        assert!(
4191            err.to_string().contains("cycle"),
4192            "the service's verdict is what refuses it: {err}"
4193        );
4194        let two = store.get(IssueSelector::Number(2)).unwrap();
4195        assert!(
4196            two.relationships.is_empty(),
4197            "SC-005: a refused mutation writes nothing"
4198        );
4199    }
4200
4201    #[test]
4202    fn f4_a_hand_ordered_related_row_is_removable() {
4203        // F4. A hand-authored `related` row in the non-canonical endpoint order.
4204        // Removals used to be keyed by where the row *should* live while rows
4205        // were found where they *were* stored, so this row matched no file and
4206        // no code path could delete it.
4207        let (_tmp, store) = make_store();
4208        seeded(&store, 2, "B", "");
4209        seeded(
4210            &store,
4211            5,
4212            "E",
4213            "relationships:\n- id: rel_handauthored\n  from: 5\n  to: 2\n  kind: related\n  display: \"related #2 B\"\n",
4214        );
4215        let out = store.unlink("5", "2", RelationKind::Related).unwrap();
4216        assert!(out.changed, "the row is found and removed");
4217        assert!(
4218            store
4219                .get(IssueSelector::Number(5))
4220                .unwrap()
4221                .relationships
4222                .is_empty()
4223        );
4224    }
4225
4226    #[test]
4227    fn f5_a_persisted_blocked_by_row_is_removable() {
4228        // F5. `blocked_by` is the input-only spelling and must never be
4229        // persisted, but the format is human-editable so a hand edit can put it
4230        // there. `unlink` compared authored fields while `link` canonicalized,
4231        // so the two disagreed and the row was un-removable — leaving a
4232        // permanently invalid row plus a `tix lint` cycle finding with no way to
4233        // clear it.
4234        let (_tmp, store) = make_store();
4235        seeded(&store, 1, "A", "");
4236        seeded(
4237            &store,
4238            2,
4239            "B",
4240            "relationships:\n- id: rel_bb\n  from: 2\n  to: 1\n  kind: blocked_by\n  display: \"blocks #1 A\"\n",
4241        );
4242        let out = store.unlink("2", "1", RelationKind::BlockedBy).unwrap();
4243        assert!(out.changed, "the row is found and removed");
4244        assert!(
4245            store
4246                .get(IssueSelector::Number(2))
4247                .unwrap()
4248                .relationships
4249                .is_empty()
4250        );
4251    }
4252
4253    #[test]
4254    fn f13_a_dangling_edge_is_removable() {
4255        // F13. A dangling edge is a diagnosed, expected condition, so the one
4256        // operation that can clean it up must still work. `unlink` resolved
4257        // both endpoints first, so deleting the target made the edge
4258        // permanently unfixable through the tool.
4259        let (_tmp, store) = make_store();
4260        seeded(&store, 1, "A", "");
4261        seeded(&store, 2, "B", "");
4262        store.link("1", "2", RelationKind::Blocks).unwrap();
4263        std::fs::remove_file(store.issues_dir.join("2026-01-01-b.md")).unwrap();
4264
4265        let out = store.unlink("1", "2", RelationKind::Blocks).unwrap();
4266        assert!(out.changed, "a dangling edge is still removable");
4267        let one = store.get(IssueSelector::Number(1)).unwrap();
4268        assert!(one.relationships.is_empty());
4269        assert_eq!(one.history.len(), 2, "and the removal is recorded");
4270    }
4271
4272    #[test]
4273    fn m10_one_unlink_of_a_duplicated_edge_appends_one_event() {
4274        // M10. The same canonical `related` edge hand-authored into **both**
4275        // endpoints — the redundancy the data model says is kept so no stored id
4276        // silently stops resolving. One `unlink` is one accepted mutation, so it
4277        // appends one History event, not one per file written. The previous
4278        // implementation computed the summary per file, and the comment directly
4279        // above it claimed the opposite.
4280        let (_tmp, store) = make_store();
4281        // A redundant pair is one row in **each** endpoint's file — the two
4282        // spellings of a single association. My first version of this fixture
4283        // put both rows in one file, which tested a different thing and then
4284        // asserted an event count for a file that was never written.
4285        seeded(
4286            &store,
4287            1,
4288            "A",
4289            "relationships:\n- id: rel_dup_12\n  from: 1\n  to: 2\n  kind: related\n  display: \"related #2 B\"\n",
4290        );
4291        seeded(
4292            &store,
4293            2,
4294            "B",
4295            "relationships:\n- id: rel_dup_21\n  from: 2\n  to: 1\n  kind: related\n  display: \"related #1 A\"\n",
4296        );
4297
4298        let outcome = store.unlink("1", "2", RelationKind::Related).unwrap();
4299        assert!(outcome.changed, "both redundant rows are removed");
4300        let mut summaries = Vec::new();
4301        for number in [1u32, 2] {
4302            let issue = store.get(IssueSelector::Number(number)).unwrap();
4303            assert!(
4304                issue.relationships.is_empty(),
4305                "issue #{number} keeps no edge"
4306            );
4307            assert_eq!(
4308                issue.history.len(),
4309                1,
4310                "issue #{number} records exactly one event, got {:?}",
4311                issue.history.events()
4312            );
4313            let event = &issue.history.events()[0];
4314            assert!(
4315                event.detail.contains("removed 2 relationships"),
4316                "and the event says both spellings went: {}",
4317                event.detail
4318            );
4319            summaries.push(event.detail.clone());
4320        }
4321        // The point of the fix: one mutation, one wording. The previous version
4322        // computed the summary per file, so each file described only its own row
4323        // and the two events disagreed about what had happened.
4324        assert_eq!(
4325            summaries[0], summaries[1],
4326            "both files record the same single event, not two differing ones"
4327        );
4328    }
4329
4330    #[test]
4331    fn m11_unlink_reports_the_row_it_removed_not_an_empty_placeholder() {
4332        // M11. The post-removal re-scan could never match — every matching row is
4333        // gone by then — so a successful unlink always reported a relationship
4334        // with an empty id, which is not addressable by
4335        // `edit_relation`/`remove_relation`. `link`'s no-op path returned a real
4336        // row; this brings the two into line.
4337        let (_tmp, store, a, b) = related_pair();
4338        let args = (a.number.to_string(), b.number.to_string());
4339        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4340        let before = store.get(IssueSelector::Number(1)).unwrap();
4341        let real_id = before.relationships[0].id.clone();
4342
4343        let outcome = store
4344            .unlink(&args.0, &args.1, RelationKind::Blocks)
4345            .unwrap();
4346        assert!(outcome.changed);
4347        assert_eq!(
4348            outcome.relationship.id, real_id,
4349            "the reported row is the one that was actually removed"
4350        );
4351        assert!(!outcome.relationship.id.is_empty(), "so it is addressable");
4352        assert_eq!(
4353            outcome.relationship.display,
4354            before.relationships[0].display
4355        );
4356    }
4357
4358    #[test]
4359    fn m15_the_history_detail_names_the_relationship_id() {
4360        // M15. `display` already begins with the kind, so naming the kind again
4361        // produced `created blocks: blocks #2 B`. The id is the stable handle and
4362        // is what data-model §8's example shows.
4363        let (_tmp, store, a, b) = related_pair();
4364        let args = (a.number.to_string(), b.number.to_string());
4365        store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4366        let one = store.get(IssueSelector::Number(1)).unwrap();
4367        let id = one.relationships[0].id.clone();
4368
4369        let detail = &one.history.events()[0].detail;
4370        assert!(
4371            detail.starts_with(&format!("created {id}:")),
4372            "the detail names the stable id: {detail}"
4373        );
4374        assert!(
4375            !detail.contains(&format!("created {}:", RelationKind::Blocks)),
4376            "and does not stutter the kind, which `display` already carries: {detail}"
4377        );
4378    }
4379
4380    #[test]
4381    fn f17_all_diagnostics_are_reported_not_just_the_first() {
4382        // F17 / M4. The previous version of this test built exactly **one**
4383        // diagnostic and asserted only that the kind prefix was not doubled —
4384        // so it never exercised the multi-diagnostic path the fix exists for.
4385        // Replacing the join with `errors.iter().take(1)` left it green.
4386        //
4387        // `1 blocks 2` plus `2 duplicates 1` makes the same pair carry two
4388        // distinct rule violations at once.
4389        // The corrupt state is hand-authored, because the write path refuses to
4390        // create it — which is the point. A file written before these rules
4391        // existed, or edited by hand, can hold several violations at once, and
4392        // `validate_snapshot` is the surface that must report all of them.
4393        let (_tmp, store) = make_store();
4394        seeded(
4395            &store,
4396            1,
4397            "A",
4398            "relationships:\n- id: rel_b12\n  from: 1\n  to: 2\n  kind: blocks\n  display: blocks\n- id: rel_d12\n  from: 1\n  to: 2\n  kind: duplicates\n  display: dup\n",
4399        );
4400        seeded(
4401            &store,
4402            2,
4403            "B",
4404            "relationships:\n- id: rel_b21\n  from: 2\n  to: 1\n  kind: blocks\n  display: blocks\n",
4405        );
4406
4407        let issues = store.list().unwrap();
4408        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
4409        let verdict = RelationshipGraph::new(&views, "done").validate_snapshot();
4410        assert!(
4411            verdict.errors.len() >= 2,
4412            "the fixture must really produce several diagnostics, got {:?}",
4413            verdict.errors
4414        );
4415
4416        let rendered = relationship_error(&verdict.errors).to_string();
4417        for kind in ["conflicting_edge", "cycle"] {
4418            assert!(
4419                rendered.contains(kind),
4420                "every diagnostic must be reported; {kind} missing from: {rendered}"
4421            );
4422        }
4423        assert!(
4424            !rendered.contains("conflicting_edge: conflicting_edge")
4425                && !rendered.contains("cycle: cycle:"),
4426            "no doubled kind prefix: {rendered}"
4427        );
4428        // M5, tested at the seam. Whether the *service* emits a duplicate
4429        // self-link diagnostic depends on which rules fire, and that has not
4430        // been reproducible from either the library or the binary — so this
4431        // asserts the property where it is decided: `relationship_error` must
4432        // never render the same (kind, message) twice, whatever it is handed.
4433        let dup = vec![
4434            Diagnostic::new(
4435                DiagnosticKind::SelfLink,
4436                "to",
4437                "#1 cannot block itself: a self-link is not a valid relationship",
4438            ),
4439            Diagnostic::new(
4440                DiagnosticKind::SelfLink,
4441                "to",
4442                "#1 cannot block itself: a self-link is not a valid relationship",
4443            ),
4444            Diagnostic::new(DiagnosticKind::Cycle, "to", "cycle: 1 -> 1 -> #1"),
4445        ];
4446        let rendered = relationship_error(&dup).to_string();
4447        assert_eq!(
4448            rendered.matches("cannot block itself").count(),
4449            1,
4450            "an identical diagnostic renders once, not twice: {rendered}"
4451        );
4452        assert!(
4453            rendered.contains("cycle"),
4454            "a distinct diagnostic is still reported: {rendered}"
4455        );
4456        assert!(
4457            !rendered.contains("self_link: self_link") && !rendered.contains("cycle: cycle:"),
4458            "and no kind is prefixed twice: {rendered}"
4459        );
4460    }
4461
4462    #[test]
4463    fn t009_clearing_a_set_removes_a_related_edge_owned_by_another_file() {
4464        // Regression, found in final review. A `related` edge is stored once, in
4465        // the lower-numbered issue's frontmatter, so #5's own file is empty even
4466        // though #5 *is* related to #3. Clearing #5's set must still remove the
4467        // association, or `tix edit 5 --links ""` silently does nothing.
4468        let (_tmp, store) = make_store();
4469        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
4470        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
4471
4472        store.link("5", "3", RelationKind::Related).unwrap();
4473        let three = store.get(IssueSelector::Number(3)).unwrap();
4474        let five = store.get(IssueSelector::Number(5)).unwrap();
4475        assert_eq!(
4476            three.relationships.len(),
4477            1,
4478            "the edge is stored in the lower-numbered issue's file"
4479        );
4480        assert!(
4481            five.relationships.is_empty(),
4482            "and #5's own file holds none"
4483        );
4484
4485        store.set_relationships(&five.filename, &[]).unwrap();
4486
4487        let three_after = store.get(IssueSelector::Number(3)).unwrap();
4488        let five_after = store.get(IssueSelector::Number(5)).unwrap();
4489        assert!(
4490            three_after.relationships.is_empty(),
4491            "the association is gone from the file that actually stored it"
4492        );
4493        assert!(five_after.relationships.is_empty());
4494    }
4495
4496    #[test]
4497    fn t009_clearing_a_set_does_not_remove_an_inbound_directional_edge() {
4498        // The other half of the ownership rule. If #3 blocks #5, that edge is
4499        // #3's declaration; #5 clearing its own relationship set must not
4500        // rewrite #3's dependency graph. Only the non-directional case is
4501        // shared, because only there does the association belong to both ends.
4502        let (_tmp, store) = make_store();
4503        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
4504        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
4505
4506        store.link("3", "5", RelationKind::Blocks).unwrap();
4507        let five = store.get(IssueSelector::Number(5)).unwrap();
4508
4509        store.set_relationships(&five.filename, &[]).unwrap();
4510
4511        let three_after = store.get(IssueSelector::Number(3)).unwrap();
4512        assert!(
4513            three_after
4514                .relationships
4515                .iter()
4516                .any(|r| r.to == 5 && r.kind == RelationKind::Blocks),
4517            "#3's declaration that it blocks #5 survives: a blocked issue \
4518             cannot unilaterally clear the blocker's graph"
4519        );
4520    }
4521
4522    /// The path of a committed relationship fixture.
4523    fn relationship_fixture(name: &str) -> String {
4524        format!(
4525            "{}/tests/fixtures/relationships/{name}",
4526            env!("CARGO_MANIFEST_DIR")
4527        )
4528    }
4529
4530    #[test]
4531    fn t010_typed_fixture_round_trips_byte_stable_including_history() {
4532        // NFR-003, model-level half: a typed fixture survives read -> write ->
4533        // read unchanged, `## History` region included.
4534        let original =
4535            std::fs::read_to_string(relationship_fixture("typed-relationships.md")).unwrap();
4536        let issue = Issue::parse(&original).unwrap();
4537        assert_eq!(issue.relationships.len(), 2);
4538        assert_eq!(issue.history.len(), 2);
4539        let rendered = issue.to_file_contents();
4540        assert_eq!(
4541            rendered, original,
4542            "a typed fixture must not churn on a read/write round trip"
4543        );
4544        assert_eq!(
4545            Issue::parse(&rendered).unwrap().to_file_contents(),
4546            rendered
4547        );
4548        // The stable ids and the section position both survive.
4549        assert_eq!(issue.relationships[0].id, "rel_01JQ8Z4W2M6N7P9R3S5T7V9X1");
4550        assert!(
4551            rendered.find("## History").unwrap() < rendered.find("Prose that follows").unwrap(),
4552            "History sits between the frontmatter and the body"
4553        );
4554    }
4555
4556    #[test]
4557    fn t010_issue_with_no_relationship_data_is_untouched() {
4558        let original =
4559            std::fs::read_to_string(relationship_fixture("no-relationship-data.md")).unwrap();
4560        let issue = Issue::parse(&original).unwrap();
4561        assert!(issue.relationships.is_empty());
4562        assert!(issue.history.is_empty());
4563        assert!(!issue.has_retired_links_key);
4564        let rendered = issue.to_file_contents();
4565        assert_eq!(
4566            rendered, original,
4567            "an issue with no relationship data must not churn"
4568        );
4569        assert!(!rendered.contains("relationships:"));
4570        assert!(!rendered.contains("## History"));
4571    }
4572
4573    #[test]
4574    fn t010_an_empty_history_section_is_dropped_not_preserved() {
4575        // A hand-left `## History` heading with no entries parses to an empty
4576        // log. The section is derived from the log, so an empty one has no
4577        // representation on disk and is omitted on write — diagnosed, never
4578        // silently filled in, and never kept as a bare heading.
4579        let original = std::fs::read_to_string(relationship_fixture("history-empty.md")).unwrap();
4580        assert!(
4581            original.contains("## History"),
4582            "the fixture must actually carry the empty heading"
4583        );
4584        let issue = Issue::parse(&original).unwrap();
4585        assert!(
4586            issue.history.is_empty(),
4587            "a heading with no entries parses to an empty log"
4588        );
4589        assert!(
4590            issue
4591                .body
4592                .contains("An issue whose History section is present but empty."),
4593            "the prose after the heading is body, not a dropped event: {:?}",
4594            issue.body
4595        );
4596        let rendered = issue.to_file_contents();
4597        assert!(
4598            !rendered.contains("## History"),
4599            "the empty section is omitted rather than kept as a bare heading"
4600        );
4601        assert!(rendered.contains("An issue whose History section is present but empty."));
4602    }
4603
4604    #[test]
4605    fn t010_issue_with_no_history_section_is_untouched() {
4606        let original = std::fs::read_to_string(relationship_fixture("history-absent.md")).unwrap();
4607        let issue = Issue::parse(&original).unwrap();
4608        assert!(issue.history.is_empty());
4609        let rendered = issue.to_file_contents();
4610        assert_eq!(
4611            rendered, original,
4612            "a file written before History existed must not gain an empty section"
4613        );
4614    }
4615
4616    #[test]
4617    fn f2_one_bad_history_line_does_not_make_the_store_unreadable() {
4618        // Store-level consequence of the model-level guarantee. Before the parse
4619        // became non-fatal, `Store::list` propagated the error, so a single
4620        // mistyped line in one issue broke `list`, `get`, `view`, `board`,
4621        // `status`, `lint`, `export`, `link`, `unlink` and `set_relationships`
4622        // for *every* issue — including `get` on a completely unrelated,
4623        // perfectly valid one. There is also no recovery through the tool,
4624        // because every command that could rewrite the file was among the
4625        // failures.
4626        let (_tmp, store) = make_store();
4627        write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
4628        write_issue(
4629            &store,
4630            "2026-01-01-c.md",
4631            "---\nnumber: 3\ntitle: \"C\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\n## History\n\n- 2026-01-01T00:00:00Z - alice - relationship_added - ascii hyphens\n",
4632        );
4633
4634        let all = store.list().expect("the store must remain listable");
4635        assert_eq!(all.len(), 2, "both issues load");
4636        assert_eq!(
4637            store
4638                .get(IssueSelector::Number(1))
4639                .expect("an unrelated, valid issue must stay readable")
4640                .title,
4641            "A"
4642        );
4643        // And the damaged line is still reachable for diagnosis, which is the
4644        // whole point of the non-fatal parse. An ASCII-hyphen line is not
4645        // *recognised*, so no event is fabricated from it, but it is retained in
4646        // the section verbatim and reported rather than demoted into the body
4647        // (see the F1 trade in `parse_front`).
4648        let three = store.get(IssueSelector::Number(3)).unwrap();
4649        assert!(three.history.events().is_empty(), "no event is fabricated");
4650        assert_eq!(
4651            three.history.unparsed().len(),
4652            1,
4653            "the line is preserved for diagnosis: {:?}",
4654            three.history.unparsed()
4655        );
4656        assert!(
4657            !three.body.contains("ascii hyphens"),
4658            "and is not demoted into the body: {:?}",
4659            three.body
4660        );
4661    }
4662
4663    /// Seed an issue into its own assignee segment, so a write can be failed
4664    /// for one owner and not another.
4665    #[cfg(unix)]
4666    fn seeded_in_segment(store: &Store, number: u32, title: &str, segment: &str) {
4667        let dir = store.issues_dir.join(segment);
4668        std::fs::create_dir_all(&dir).unwrap();
4669        std::fs::write(
4670            dir.join(format!("2026-01-01-{}.md", title.to_lowercase())),
4671            format!(
4672                "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
4673            ),
4674        )
4675        .unwrap();
4676    }
4677
4678    #[cfg(unix)]
4679    #[test]
4680    fn f6_a_mid_sequence_write_failure_rolls_every_file_back() {
4681        use std::os::unix::fs::PermissionsExt;
4682        // F6 / anti-pattern 7. This can write N files, because an edge is stored
4683        // in its canonical source's file, so a failure partway through used to
4684        // leave the store half-mutated while the command reported failure.
4685        //
4686        // `atomic_write` renames a temp file, which needs write permission on
4687        // the *directory*, so the injection makes one owner's segment
4688        // read-only. The owners are 1, 2 and 3 and are written in ascending
4689        // order, so #1 and #2 are committed before #3 fails.
4690        let (tmp, store) = make_store();
4691        let _ = tmp;
4692        seeded_in_segment(&store, 1, "A", "alice");
4693        seeded_in_segment(&store, 2, "B", "bob");
4694        seeded_in_segment(&store, 3, "C", "carol");
4695        seeded_in_segment(&store, 5, "E", "erin");
4696
4697        let paths: Vec<std::path::PathBuf> = [
4698            "alice/2026-01-01-a.md",
4699            "bob/2026-01-01-b.md",
4700            "carol/2026-01-01-c.md",
4701        ]
4702        .iter()
4703        .map(|p| store.issues_dir.join(p))
4704        .collect();
4705        let before: Vec<String> = paths
4706            .iter()
4707            .map(|p| std::fs::read_to_string(p).unwrap())
4708            .collect();
4709
4710        // Order-normalization puts target 5's edge on the lower number, so the
4711        // owners really are 1, 2 and 3 and #3 is written last.
4712        let carol = store.issues_dir.join("carol");
4713        let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4714        perms.set_mode(0o555);
4715        std::fs::set_permissions(&carol, perms).unwrap();
4716
4717        let result = store.set_relationships(
4718            "carol/2026-01-01-c.md",
4719            &[
4720                (1, RelationKind::Related),
4721                (2, RelationKind::Related),
4722                (5, RelationKind::Related),
4723            ],
4724        );
4725
4726        let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4727        perms.set_mode(0o755);
4728        std::fs::set_permissions(&carol, perms).unwrap();
4729
4730        assert!(result.is_err(), "the blocked write must fail");
4731        for (path, previous) in paths.iter().zip(before.iter()) {
4732            assert_eq!(
4733                std::fs::read_to_string(path).unwrap(),
4734                *previous,
4735                "{} was left mutated by a failed mutation",
4736                path.display()
4737            );
4738        }
4739    }
4740
4741    #[cfg(unix)]
4742    #[test]
4743    fn f6b_the_updated_bump_is_itself_rollback_covered() {
4744        // The `updated` bump on the named issue (F16) is a real write, and it
4745        // used to sit *after* the rollback check — so a failure there returned an
4746        // error with the relationship writes already committed and nothing left
4747        // to restore. That is the same class of defect F6 was about, reintroduced
4748        // by the F16 fix.
4749        //
4750        // Order matters here: the relationship write lands in the *other*
4751        // endpoint's segment, and the bump targets the named issue's own
4752        // segment, so blocking the named segment fails the second write.
4753        use std::os::unix::fs::PermissionsExt;
4754
4755        let (tmp, store) = make_store();
4756        let _ = tmp;
4757        seeded_in_segment(&store, 1, "A", "alice");
4758        seeded_in_segment(&store, 3, "C", "carol");
4759
4760        let alice = store.issues_dir.join("alice/2026-01-01-a.md");
4761        let before = std::fs::read_to_string(&alice).unwrap();
4762
4763        let carol = store.issues_dir.join("carol");
4764        let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4765        perms.set_mode(0o555);
4766        std::fs::set_permissions(&carol, perms).unwrap();
4767
4768        let result =
4769            store.set_relationships("carol/2026-01-01-c.md", &[(1, RelationKind::Related)]);
4770
4771        let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4772        perms.set_mode(0o755);
4773        std::fs::set_permissions(&carol, perms).unwrap();
4774
4775        assert!(result.is_err(), "the blocked bump must fail the call");
4776        assert_eq!(
4777            std::fs::read_to_string(&alice).unwrap(),
4778            before,
4779            "the relationship write that succeeded earlier must be rolled back \
4780             when the subsequent `updated` bump fails"
4781        );
4782    }
4783
4784    #[test]
4785    fn f9_relationships_for_reports_both_endpoints_of_a_one_sided_edge() {
4786        // F9. An edge is stored once, in its canonical source's file, so the
4787        // issue's own `relationships` field is a *partial* view. Reading it made
4788        // a related issue look unrelated — a user-visible regression against the
4789        // retired symmetric model, which mirrored the link on both sides by
4790        // construction.
4791        let (_tmp, store) = make_store();
4792        seeded(&store, 1, "A", "");
4793        seeded(&store, 2, "B", "");
4794        store.link("2", "1", RelationKind::Related).unwrap();
4795
4796        // The edge is stored once, in the lower-numbered issue's file.
4797        let one = store.get(IssueSelector::Number(1)).unwrap();
4798        let two = store.get(IssueSelector::Number(2)).unwrap();
4799        assert_eq!(one.relationships.len(), 1, "precondition: #1 stores it");
4800        assert!(
4801            two.relationships.is_empty(),
4802            "precondition: #2's own field is the partial view that caused F9"
4803        );
4804
4805        // The service-backed accessor is what a read surface must use, and it
4806        // answers for both endpoints.
4807        for number in [1u32, 2] {
4808            let resolved = store.relationships_for(number).unwrap();
4809            assert_eq!(
4810                resolved.len(),
4811                1,
4812                "issue #{number} must see the relationship that touches it"
4813            );
4814            assert_eq!(resolved[0].kind, RelationKind::Related);
4815        }
4816    }
4817
4818    #[test]
4819    fn f7_writing_an_issue_with_the_retired_key_discloses_that_it_is_removed() {
4820        // F7 / M6. The previous version of this test asserted only the
4821        // *consequence* — the key is gone from the file — which was already true
4822        // before the fix, so removing the disclosure entirely left it green.
4823        // It never checked the thing its name promised.
4824        let (tmp, store) = make_store();
4825        let _ = tmp;
4826        let _ = take_disclosures();
4827        // **Populated**, not `[]`. The disclosure fires because the key's value is
4828        // no longer readable, and an empty value has nothing to lose — reporting
4829        // the key's mere presence made `tix lint` reject every issue file this
4830        // repository has ever written, since they all carry a `links: []`.
4831        write_issue(
4832            &store,
4833            "2026-01-01-legacy.md",
4834            &legacy_links_frontmatter(1, "Legacy", "['7']"),
4835        );
4836        let issue = store.get(IssueSelector::Number(1)).unwrap();
4837        assert!(
4838            issue.has_retired_links_key,
4839            "precondition: the key is present"
4840        );
4841
4842        store.update(&issue.filename, &issue).unwrap();
4843
4844        // The disclosure, which is the whole point of the fix.
4845        let disclosures = take_disclosures();
4846        assert_eq!(
4847            disclosures.len(),
4848            1,
4849            "the write must disclose that it dropped the key: {disclosures:?}"
4850        );
4851        let line = &disclosures[0];
4852        assert!(
4853            line.contains("`links:`"),
4854            "it names what was removed: {line}"
4855        );
4856        // The action leads, so it is the part that survives truncation in the
4857        // TUI's one-row status area. Cycle 6 measured the previous wording being
4858        // cut off at 80, 100 and 120 columns.
4859        assert!(
4860            line.starts_with("remove `links:` by hand"),
4861            "and leads with what to do about it: {line}"
4862        );
4863        assert!(
4864            line.contains(&issue.filename),
4865            "and which file it was removed from: {line}"
4866        );
4867        assert!(
4868            line.contains("C-001"),
4869            "and that no migration was applied, so the user is not left \
4870             wondering where their data went: {line}"
4871        );
4872
4873        // The consequence the disclosure is about.
4874        let after = read_issue_file(&store, &issue.filename);
4875        assert!(
4876            !after.contains("links:"),
4877            "the key is not re-emitted (C-001: no migration)"
4878        );
4879        // And a re-read no longer reports it, which is precisely why the
4880        // disclosure matters: the diagnostic is self-erasing without it.
4881        let reread = store.get(IssueSelector::Number(1)).unwrap();
4882        assert!(!reread.has_retired_links_key);
4883        assert!(
4884            take_disclosures().is_empty(),
4885            "and it is disclosed once, not on every read"
4886        );
4887    }
4888
4889    #[test]
4890    fn f7b_writing_an_issue_without_the_retired_key_is_silent() {
4891        // The disclosure is scoped to the condition that triggers it, so the
4892        // ordinary path stays quiet and this queue cannot become noise.
4893        let (tmp, store) = make_store();
4894        let _ = tmp;
4895        let _ = take_disclosures();
4896        write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
4897        let issue = store.get(IssueSelector::Number(1)).unwrap();
4898        assert!(!issue.has_retired_links_key);
4899        store.update(&issue.filename, &issue).unwrap();
4900        assert!(
4901            take_disclosures().is_empty(),
4902            "an ordinary write discloses nothing"
4903        );
4904    }
4905
4906    #[test]
4907    fn t010_legacy_fixture_reports_the_retired_key_and_yields_no_edges() {
4908        // C-001, the compatibility/error matrix in one place: tolerant read,
4909        // explicit diagnostic, and zero reinterpretation.
4910        let original =
4911            std::fs::read_to_string(relationship_fixture("legacy-links-key.md")).unwrap();
4912        let issue = Issue::parse(&original).unwrap();
4913        assert!(issue.has_retired_links_key, "the retired key is reported");
4914        assert!(
4915            issue.relationships.is_empty(),
4916            "C-001: legacy values are never reinterpreted as typed edges"
4917        );
4918        assert!(
4919            issue.history.is_empty(),
4920            "a legacy file gains no History section from parsing"
4921        );
4922        // The diagnostic names the field and is located in the file, per the
4923        // validation contract. Exercised through the real `tix lint` path so
4924        // the test covers the code that actually ships.
4925        let (_tmp, store) = make_store();
4926        write_issue(&store, "2026-01-01-legacy.md", &original);
4927        let findings = crate::commands::lint::collect(&store.config, &store).unwrap();
4928        let retired: Vec<_> = findings
4929            .iter()
4930            .filter(|f| f.kind == "retired_links_key")
4931            .collect();
4932        assert_eq!(
4933            retired.len(),
4934            1,
4935            "one finding for the retired key: {findings:?}"
4936        );
4937        assert_eq!(retired[0].value, "links", "the finding names the field");
4938        assert!(
4939            retired[0].message.contains("retired"),
4940            "the message says why: {}",
4941            retired[0].message
4942        );
4943        assert!(
4944            retired[0].line > 0,
4945            "the finding points into the frontmatter"
4946        );
4947        // No code path converts the legacy value into a typed edge.
4948        let rewritten = issue.to_file_contents();
4949        assert!(!rewritten.contains("kind: related"));
4950        assert!(!rewritten.contains("kind: blocks"));
4951        assert!(!rewritten.contains("links:"));
4952    }
4953
4954    #[test]
4955    fn c001_a_legacy_links_key_is_reported_and_yields_no_typed_edges() {
4956        let (tmp, store) = make_store();
4957        write_issue(
4958            &store,
4959            "2026-01-01-legacy.md",
4960            &legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
4961        );
4962        let _ = tmp;
4963        let issue = store.get(IssueSelector::Number(1)).unwrap();
4964        assert!(
4965            issue.has_retired_links_key,
4966            "the retired key is recorded as a validation result"
4967        );
4968        assert!(
4969            issue.relationships.is_empty(),
4970            "C-001: legacy values are never read as typed edges"
4971        );
4972    }
4973
4974    #[test]
4975    fn c001_there_is_no_migration_path_from_the_retired_key() {
4976        // This test used to say "writing the issue back is the only way a
4977        // migration could occur" and then never write it back, so its
4978        // load-bearing assertion was on an in-memory flag after a pure string
4979        // render — which cannot change it. The actual write-back is where the
4980        // key disappears, so the test as written masked the very thing it
4981        // appeared to guard.
4982        //
4983        // It now performs the write and asserts on the resulting bytes.
4984        let (tmp, store) = make_store();
4985        let _ = tmp;
4986        write_issue(
4987            &store,
4988            "2026-01-01-legacy.md",
4989            &legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
4990        );
4991        let issue = store.get(IssueSelector::Number(1)).unwrap();
4992        assert!(
4993            issue.has_retired_links_key,
4994            "precondition: the key is present"
4995        );
4996        assert!(
4997            issue.relationships.is_empty(),
4998            "C-001: legacy values are never read as typed edges"
4999        );
5000
5001        store.update(&issue.filename, &issue).unwrap();
5002        let written = read_issue_file(&store, &issue.filename);
5003
5004        // No migration: not one legacy value became a typed edge, under any kind.
5005        //
5006        // The re-read matters, and this test has already fallen into the trap
5007        // once: asserting on `issue.to_file_contents()` instead of the written
5008        // file cannot fail, because `Issue::parse` never populates
5009        // `relationships`, so the in-memory render is clean whatever the *writer*
5010        // does. Reading the file back off disk is the only form of this check
5011        // that the write-back actually has to satisfy.
5012        let reparsed = store.get(IssueSelector::Number(1)).unwrap();
5013        assert!(
5014            reparsed.relationships.is_empty(),
5015            "the file on disk parses to zero typed edges, not just the in-memory copy"
5016        );
5017        for kind in ["blocks", "blocked_by", "duplicates", "related"] {
5018            assert!(
5019                !written.contains(&format!("kind: {kind}")),
5020                "a legacy link became a `{kind}` edge:\n{written}"
5021            );
5022        }
5023        assert!(
5024            !written.contains("kind:"),
5025            "no typed edge was written at all"
5026        );
5027        // The retired key is not re-emitted, and the write did not manufacture
5028        // a History entry for something the user did not do.
5029        assert!(!written.contains("links:"), "the key is not re-emitted");
5030        let reread = store.get(IssueSelector::Number(1)).unwrap();
5031        assert!(
5032            reread.history.is_empty(),
5033            "a format change is not a relationship mutation, so it records no event"
5034        );
5035    }
5036
5037    #[test]
5038    fn f15_the_idempotent_no_op_reports_the_real_addressable_row() {
5039        // F15. The no-op path returned a synthetic `Relationship` with an empty
5040        // id and display. The contract defines the output as the accepted
5041        // relationship, and an empty id is not addressable by
5042        // `edit_relation`/`remove_relation`, so a caller that persisted it would
5043        // store a dangling reference.
5044        let (_tmp, store, a, b) = related_pair();
5045        let args = (a.number.to_string(), b.number.to_string());
5046        let first = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5047        let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5048
5049        assert!(first.changed);
5050        assert!(!second.changed, "the second apply is a no-op");
5051        assert_eq!(
5052            second.relationship.id, first.relationship.id,
5053            "the no-op reports the real, addressable row"
5054        );
5055        assert!(
5056            !second.relationship.id.is_empty(),
5057            "an empty id is not addressable"
5058        );
5059        assert_eq!(second.relationship.display, first.relationship.display);
5060    }
5061
5062    #[test]
5063    fn f16_clearing_a_set_bumps_updated_on_the_named_issue() {
5064        // F16. The named issue's own frontmatter can be untouched while its
5065        // relationship set changed, because an order-normalized `related` edge
5066        // lives in the other endpoint's file. That left `tix edit <n> --links ""`
5067        // looking like it had done nothing to the issue it named.
5068        let (_tmp, store, _a, _b) = related_pair();
5069        store.link("2", "1", RelationKind::Related).unwrap();
5070        let before = store.get(IssueSelector::Number(2)).unwrap();
5071        let updated_before = before.updated.clone();
5072
5073        store.set_relationships(&before.filename, &[]).unwrap();
5074
5075        let after = store.get(IssueSelector::Number(2)).unwrap();
5076        assert_ne!(
5077            after.updated, updated_before,
5078            "the named issue's relationship set changed, so `updated` must move"
5079        );
5080    }
5081
5082    #[test]
5083    fn c002_an_issue_with_no_relationship_data_round_trips_unchanged() {
5084        let (tmp, store) = make_store();
5085        let _ = tmp;
5086        // A pre-existing issue file: no relationship data, no History. This is
5087        // the shape every file in the repository has today, and reading then
5088        // writing it must not churn it (the WP's "existing files must not be
5089        // rewritten" guard).
5090        write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
5091        let issue = store.get(IssueSelector::Number(1)).unwrap();
5092        let first = issue.to_file_contents();
5093        assert!(
5094            !first.contains("relationships:"),
5095            "the empty typed field is omitted, not written as `[]`"
5096        );
5097        assert!(
5098            !first.contains("## History"),
5099            "an empty History section is omitted entirely"
5100        );
5101        // Byte-stable through read -> write -> read, and no unrelated
5102        // frontmatter key is added, removed, or reordered.
5103        let reparsed = Issue::parse(&first).unwrap();
5104        let second = reparsed.to_file_contents();
5105        assert_eq!(second, first);
5106    }
5107
5108    #[test]
5109    fn c002_history_section_round_trips_byte_stable() {
5110        let (tmp, store) = make_store();
5111        write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
5112        let _ = tmp;
5113        let mut issue = store.get(IssueSelector::Number(1)).unwrap();
5114        issue.history.append_now(
5115            "alice",
5116            HistoryEventKind::RelationshipAdded,
5117            "created blocks: blocks #2 B",
5118        );
5119        issue.history.append_now(
5120            "bob",
5121            HistoryEventKind::BlockedMoveAcknowledged,
5122            "acknowledged 1 open blocker",
5123        );
5124        let first = issue.to_file_contents();
5125        assert!(first.contains("## History"), "{first}");
5126
5127        // Read -> write -> read must be byte-stable.
5128        let reparsed = Issue::parse(&first).unwrap();
5129        assert_eq!(reparsed.to_file_contents(), first);
5130        assert_eq!(reparsed.history.len(), 2);
5131        assert_eq!(reparsed.history.events()[0].actor, "alice");
5132        assert_eq!(
5133            reparsed.history.events()[1].kind,
5134            HistoryEventKind::BlockedMoveAcknowledged
5135        );
5136        assert!(
5137            !reparsed.body.contains("History"),
5138            "the section is stripped from the body: {:?}",
5139            reparsed.body
5140        );
5141    }
5142
5143    #[test]
5144    fn f1_a_hand_edit_last_in_the_section_stays_in_it() {
5145        // Cycle-4 F1. The disambiguation rule carried a `!following.is_empty()`
5146        // guard, added to stop an earlier vacuous-truth defect. It over-corrected:
5147        // a hand-edited line that is the **last** line of the section has nothing
5148        // after it, so the guard forced the section to terminate there. Through
5149        // the real binary that meant `tix move 1 "in progress"` silently
5150        //
5151        //   - deleted the `## History` heading,
5152        //   - demoted the author's line into the issue body, and
5153        //   - rendered the *next* appended event above it, so file order stopped
5154        //     being append order,
5155        //
5156        // and `tix lint` reported nothing, because a demoted line is body prose
5157        // and the unparsed loop no longer sees it. Three explicit data-model §8
5158        // invariants at once.
5159        //
5160        // The distinguishing question is not "is there anything after this?" but
5161        // "is this shaped like a list item?". Nothing-following cannot disqualify
5162        // a line; a bare paragraph at EOF is still plainly prose.
5163        let (tmp, store) = make_store();
5164        let _ = tmp;
5165        let original = "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n  - my hand edit\n";
5166        write_issue(&store, "2026-01-01-a.md", original);
5167
5168        // Read precondition: the hand edit is inside the section, and the section
5169        // holds the prior event.
5170        let before = store.get(IssueSelector::Number(1)).unwrap();
5171        assert_eq!(
5172            before.history.events().len(),
5173            1,
5174            "precondition: the real event parsed"
5175        );
5176        assert_eq!(
5177            before.history.unparsed(),
5178            vec!["  - my hand edit"],
5179            "precondition: the hand edit is inside the section"
5180        );
5181        assert_eq!(before.body, "", "precondition: body is empty");
5182
5183        // An ordinary command that has nothing to do with relationships.
5184        let mut moved = before.clone();
5185        moved.status = crate::model::Status::new("in progress");
5186        store.update(&moved.filename, &moved).unwrap();
5187
5188        let after = store.get(IssueSelector::Number(1)).unwrap();
5189        assert_eq!(
5190            after.history.unparsed(),
5191            vec!["  - my hand edit"],
5192            "the hand edit is still in the audit log, not demoted to the body"
5193        );
5194        assert_eq!(
5195            after.history.events().len(),
5196            1,
5197            "and the real event survived"
5198        );
5199        assert_eq!(after.body, "", "nothing leaked into the body");
5200
5201        // The heading is still there, and the next append goes *after* what was
5202        // already in the file.
5203        store
5204            .link("1", "2", RelationKind::Related)
5205            .ok()
5206            .or_else(|| {
5207                write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5208                store.link("1", "2", RelationKind::Related).ok()
5209            });
5210        let linked = store.get(IssueSelector::Number(1)).unwrap();
5211        let rendered = linked.to_file_contents();
5212        assert!(
5213            rendered.contains("## History"),
5214            "the heading survived an unrelated write: {rendered}"
5215        );
5216        let hand = rendered
5217            .find("my hand edit")
5218            .expect("the hand edit is present");
5219        let newest = rendered
5220            .find("relationship_added")
5221            .expect("the new event is present");
5222        assert!(
5223            newest < hand,
5224            "the new event is appended AFTER the pre-existing content, so file \
5225             order is still append order: {rendered}"
5226        );
5227    }
5228
5229    #[test]
5230    fn f1_but_a_bare_paragraph_at_end_of_section_is_still_prose() {
5231        // The other half of F1's rule, and the reason the fix is not simply
5232        // "vacuous truth is fine". A non-list line at EOF is body prose: keeping
5233        // it would put ordinary prose into the append-only audit log, which is
5234        // the defect cycle 3 found (N1) travelling in the opposite direction.
5235        let (tmp, store) = make_store();
5236        let _ = tmp;
5237        write_issue(
5238            &store,
5239            "2026-01-01-a.md",
5240            "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n\njust a closing paragraph\n",
5241        );
5242        let issue = store.get(IssueSelector::Number(1)).unwrap();
5243        assert_eq!(issue.history.events().len(), 1, "the real event parsed");
5244        assert!(
5245            issue.history.unparsed().is_empty(),
5246            "the paragraph is not in the audit log: {:?}",
5247            issue.history.unparsed()
5248        );
5249        assert!(
5250            issue.body.contains("just a closing paragraph"),
5251            "it is body prose: {:?}",
5252            issue.body
5253        );
5254    }
5255
5256    #[test]
5257    fn f1_a_hand_edit_survives_body_prose_and_a_later_body_replacement() {
5258        // Cycle-5 Blocker 1. Cycle 4's F1 fix consulted `is_list_item` *only*
5259        // when nothing followed the ambiguous line, so the whole defect survived
5260        // for any hand-edited entry with body prose after it. The existing F1
5261        // tests could not see it because every one of their fixtures asserts
5262        // `before.body == ""` — the empty body was precisely what hid it.
5263        //
5264        // The consequence was data loss, not just reordering. A demoted line is
5265        // body text, and `tix edit --body` replaces the body wholesale, so:
5266        //
5267        //   ## History / - <event> / - 2026-02-02T00:00:00Z - bob \
5268        //     - relationship_removed - I removed this by hand / (blank) / Notes.
5269        //
5270        // then `tix edit 1 --body='Completely new body text.'` destroyed the
5271        // hand-edited append-only audit entry, and lint was silent.
5272        let (_tmp, store) = make_store();
5273        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5274        let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed - by hand";
5275        write_issue(
5276            &store,
5277            "2026-01-01-a.md",
5278            &format!(
5279                "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n{hand}\n\nSome body prose.\n"
5280            ),
5281        );
5282
5283        let before = store.get(IssueSelector::Number(1)).unwrap();
5284        assert_eq!(before.history.events().len(), 1, "the real event parsed");
5285        assert_eq!(
5286            before.history.unparsed(),
5287            vec![hand],
5288            "the hand-edited entry is retained even though body prose follows"
5289        );
5290        assert!(
5291            before.body.contains("Some body prose."),
5292            "and the prose is still body text: {:?}",
5293            before.body
5294        );
5295
5296        // An ordinary unrelated write must not relocate it.
5297        let mut moved = before.clone();
5298        moved.status = crate::model::Status::new("in progress");
5299        store.update(&moved.filename, &moved).unwrap();
5300        assert_eq!(
5301            store
5302                .get(IssueSelector::Number(1))
5303                .unwrap()
5304                .history
5305                .unparsed(),
5306            vec![hand],
5307            "a `tix move` keeps it in the audit log"
5308        );
5309
5310        // And replacing the body — the operation that used to destroy it — must
5311        // not either.
5312        let mut replaced = store.get(IssueSelector::Number(1)).unwrap();
5313        replaced.body = "Completely new body text.".to_string();
5314        store.update(&replaced.filename, &replaced).unwrap();
5315        let after = store.get(IssueSelector::Number(1)).unwrap();
5316        assert_eq!(
5317            after.history.unparsed(),
5318            vec![hand],
5319            "the hand-edited entry survives a wholesale body replacement"
5320        );
5321        assert_eq!(after.body, "Completely new body text.");
5322
5323        // And the next append still goes after what the file already held.
5324        store.link("1", "2", RelationKind::Related).unwrap();
5325        let rendered = store
5326            .get(IssueSelector::Number(1))
5327            .unwrap()
5328            .to_file_contents();
5329        assert!(
5330            rendered.contains("## History"),
5331            "the heading survived: {rendered}"
5332        );
5333        let hand_at = rendered.find("by hand").expect("the hand edit is present");
5334        let new_at = rendered
5335            .find("created rel_")
5336            .expect("the new event is present");
5337        assert!(
5338            new_at > hand_at,
5339            "the new event is appended AFTER the pre-existing content, so file \
5340             order is still append order: {rendered}"
5341        );
5342    }
5343
5344    #[test]
5345    fn f1_a_well_formed_star_marked_entry_is_retained_with_body_prose_after_it() {
5346        // Cycle-6 F1. A *correctly formatted* entry written with a `*` marker was
5347        // demoted to the body and then destroyed by `tix edit --body`, with lint
5348        // silent. The cause was a collapsed predicate: `looks_like_history_entry`
5349        // and `HistoryEvent::parse` both require the `- ` prefix, so a `*`-marked
5350        // em-dash line was not *recognised*; and the retention test asked
5351        // "does it look like a *misspelled* entry?", which explicitly excludes
5352        // anything containing U+2014. So a well-formed line matched neither.
5353        // Retention now asks its own question — is this an entry attempt under any
5354        // marker?
5355        let (_tmp, store) = make_store();
5356        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5357        let hand =
5358            "* 2026-02-02T00:00:00Z \u{2014} bob \u{2014} relationship_removed \u{2014} by hand";
5359        write_issue(
5360            &store,
5361            "2026-01-01-a.md",
5362            &format!(
5363                "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n{hand}\n\nSome body prose.\n"
5364            ),
5365        );
5366
5367        let before = store.get(IssueSelector::Number(1)).unwrap();
5368        assert_eq!(before.history.events().len(), 1, "the real event parsed");
5369        assert_eq!(
5370            before.history.unparsed(),
5371            vec![hand],
5372            "a well-formed `*` entry is retained even with body prose after it"
5373        );
5374        assert!(
5375            before.body.contains("Some body prose."),
5376            "and the prose is still body text: {:?}",
5377            before.body
5378        );
5379
5380        // The destructive sequence, which is the whole point.
5381        let mut replaced = before.clone();
5382        replaced.body = "Completely new body text.".to_string();
5383        store.update(&replaced.filename, &replaced).unwrap();
5384        assert_eq!(
5385            store
5386                .get(IssueSelector::Number(1))
5387                .unwrap()
5388                .history
5389                .unparsed(),
5390            vec![hand],
5391            "it survives a wholesale body replacement"
5392        );
5393
5394        store.link("1", "2", RelationKind::Related).unwrap();
5395        let rendered = store
5396            .get(IssueSelector::Number(1))
5397            .unwrap()
5398            .to_file_contents();
5399        assert_eq!(
5400            rendered.matches("## History").count(),
5401            1,
5402            "still exactly one section: {rendered}"
5403        );
5404        let hand_at = rendered.find("by hand").expect("the hand edit is present");
5405        let new_at = rendered
5406            .find("created rel_")
5407            .expect("the new event is present");
5408        assert!(
5409            new_at > hand_at,
5410            "appended after, so order is append order: {rendered}"
5411        );
5412    }
5413
5414    #[test]
5415    fn f1_a_three_field_ascii_hyphen_entry_is_retained_with_body_prose_after_it() {
5416        // Cycle-6 F2. `HistoryEvent::parse` accepts three total fields with
5417        // `detail` optional, so `- <ts> - <actor> - <kind>` is a valid entry whose
5418        // only defect is the separator. The retention test required four, so that
5419        // line was demoted and then destroyed while the malformed four-field one
5420        // survived — the recoverable direction was the one that lost.
5421        let (_tmp, store) = make_store();
5422        let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed";
5423        write_issue(
5424            &store,
5425            "2026-01-01-a.md",
5426            &format!(
5427                "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n{hand}\n\nSome body prose.\n"
5428            ),
5429        );
5430
5431        // Precondition: the em-dash twin really is a valid event, so the
5432        // three-field form is in fact part of the format.
5433        let twin = Issue::parse(
5434            "---\nnumber: 2\ntitle: \"T\"\nstatus: todo\n---\n\n## History\n\n- 2026-02-02T00:00:00Z \u{2014} bob \u{2014} relationship_removed\n",
5435        )
5436        .unwrap();
5437        assert_eq!(
5438            twin.history.events().len(),
5439            1,
5440            "three fields with em-dashes is a valid event, detail optional"
5441        );
5442
5443        let before = store.get(IssueSelector::Number(1)).unwrap();
5444        assert_eq!(
5445            before.history.unparsed(),
5446            vec![hand],
5447            "so its ASCII-hyphen twin is retained, not demoted"
5448        );
5449        let mut replaced = before.clone();
5450        replaced.body = "Completely new body text.".to_string();
5451        store.update(&replaced.filename, &replaced).unwrap();
5452        assert_eq!(
5453            store
5454                .get(IssueSelector::Number(1))
5455                .unwrap()
5456                .history
5457                .unparsed(),
5458            vec![hand],
5459            "and survives a body replacement"
5460        );
5461    }
5462
5463    #[test]
5464    fn f1_a_heading_with_trailing_spaces_is_still_the_heading() {
5465        // Cycle-6 F3(c). `## History   ` was not recognised, and since the
5466        // heading is then neither the section nor body, the first relationship
5467        // write rendered a **second** `## History` above the original. That was
5468        // filed on #1299 as a CRLF-only consequence, which understated it: a
5469        // trailing space is a far likelier trigger than CRLF.
5470        let (_tmp, store) = make_store();
5471        write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5472        for (label, heading) in [
5473            ("plain", "## History"),
5474            ("trailing spaces", "## History   "),
5475        ] {
5476            write_issue(
5477                &store,
5478                "2026-01-01-a.md",
5479                &format!(
5480                    "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n{heading}\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n"
5481                ),
5482            );
5483            let before = store.get(IssueSelector::Number(1)).unwrap();
5484            assert_eq!(
5485                before.history.events().len(),
5486                1,
5487                "{label}: the section is recognised"
5488            );
5489            store.link("1", "2", RelationKind::Related).unwrap();
5490            let rendered = store
5491                .get(IssueSelector::Number(1))
5492                .unwrap()
5493                .to_file_contents();
5494            assert_eq!(
5495                rendered.matches("## History").count(),
5496                1,
5497                "{label}: exactly one section after a write, got {rendered}"
5498            );
5499            assert!(
5500                !rendered.lines().any(|l| l != l.trim_end()),
5501                "{label}: no line keeps trailing whitespace, so the heading is \
5502                 normalised: {rendered}"
5503            );
5504        }
5505    }
5506
5507    #[test]
5508    fn set_status_discloses_the_retired_key_rather_than_dropping_it_silently() {
5509        // Cycle-6 F4. Routing `set_status` through `update` is what makes it
5510        // disclose, and the behavioural difference is real — reverted to a direct
5511        // `atomic_write`, the key is still removed but nothing is disclosed, which
5512        // is silent data loss. Reverting also left the **entire suite green**,
5513        // because no test exercised it. That is the sixth time this package has
5514        // produced a correct fix with no regression protection, so this pins it.
5515        let (_tmp, store) = make_store();
5516        write_issue(
5517            &store,
5518            "2026-01-01-a.md",
5519            "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nlinks:\n- \"2\"\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n",
5520        );
5521        let before = store.get(IssueSelector::Number(1)).unwrap();
5522        assert!(
5523            before.has_retired_links_key,
5524            "precondition: the file carries the retired key"
5525        );
5526        let _ = crate::storage::take_disclosures();
5527
5528        store
5529            .set_status("2026-01-01-a.md", StatusChange::to("in progress"))
5530            .unwrap();
5531
5532        let disclosures = crate::storage::take_disclosures();
5533        assert_eq!(
5534            disclosures.len(),
5535            1,
5536            "set_status must disclose the removal, got {disclosures:?}"
5537        );
5538        assert!(
5539            disclosures[0].contains("links:") && disclosures[0].contains("a.md"),
5540            "naming the field and the file: {disclosures:?}"
5541        );
5542        assert!(
5543            !store
5544                .get(IssueSelector::Number(1))
5545                .unwrap()
5546                .has_retired_links_key,
5547            "and the key is gone, as C-001 intends"
5548        );
5549    }
5550
5551    #[test]
5552    fn f1_but_ordinary_prose_bullets_still_end_the_section() {
5553        // The other direction, and the reason the retention rule is not simply
5554        // "keep every list item". N1's original trigger: several body bullets in
5555        // a row. Neither is entry-shaped, so neither is plausibly a hand-edited
5556        // entry, and the section ends where it should.
5557        let (_tmp, store) = make_store();
5558        write_issue(
5559            &store,
5560            "2026-01-01-a.md",
5561            concat!(
5562                "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\n",
5563                "created: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n",
5564                "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n\n",
5565                "- step one\n- step two\n",
5566            ),
5567        );
5568        let issue = store.get(IssueSelector::Number(1)).unwrap();
5569        assert_eq!(
5570            issue.history.events().len(),
5571            1,
5572            "the real event is an event"
5573        );
5574        assert!(
5575            issue.history.unparsed().is_empty(),
5576            "ordinary bullets are not retained: {:?}",
5577            issue.history.unparsed()
5578        );
5579        assert!(
5580            issue.body.contains("step one") && issue.body.contains("step two"),
5581            "they stay body text: {:?}",
5582            issue.body
5583        );
5584    }
5585
5586    #[test]
5587    fn a_year_is_not_an_ordered_list_marker() {
5588        // Cycle-5 Minor 7. `is_list_item` accepted any run of digits before `.`,
5589        // so `2026. Q1 goals` counted as a list item and a paragraph of ordinary
5590        // body prose was retained in the append-only audit log.
5591        for prose in [
5592            "2026. Q1 goals",
5593            "2026. roadmap review",
5594            "007. padded",
5595            "99999999999999999999. big",
5596        ] {
5597            assert!(
5598                !crate::model::looks_like_an_unrecognised_entry(prose)
5599                    && Issue::parse(&format!(
5600                        "---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{prose}\n"
5601                    ))
5602                    .unwrap()
5603                    .history
5604                    .is_empty(),
5605                "{prose:?} must be body prose, not a retained entry"
5606            );
5607        }
5608    }
5609
5610    #[test]
5611    fn f2_a_recognised_entry_with_a_bad_kind_keeps_its_trailing_whitespace() {
5612        // Cycle-4 F2. The `Err` arm — a *recognised* entry whose kind is unknown —
5613        // used `trim_end()`, which also eats trailing spaces and tabs. That is
5614        // the auto-repair data-model §8 forbids, and it contradicts §8's
5615        // "preserved byte-for-byte, including its own indentation and trailing
5616        // whitespace". The sibling ambiguous arm was already correct, so the two
5617        // disagreed about what "verbatim" means.
5618        let line = "  - 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} d   ";
5619        let raw = format!(
5620            "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n{line}\n"
5621        );
5622        let issue = Issue::parse(&raw).unwrap();
5623        assert!(issue.history.events().is_empty(), "no event was fabricated");
5624        assert_eq!(
5625            issue.history.unparsed(),
5626            vec![line],
5627            "kept byte-for-byte, trailing spaces included"
5628        );
5629
5630        // And a real write must not repair it either.
5631        let rendered = issue.to_file_contents();
5632        assert!(
5633            rendered.contains(&format!("{line}\n")),
5634            "re-emitted exactly, so an unrelated `tix edit` does not rewrite the \
5635             author's audit log: {rendered}"
5636        );
5637        assert_eq!(
5638            Issue::parse(&rendered).unwrap().to_file_contents(),
5639            rendered,
5640            "and the file is byte-stable from here"
5641        );
5642    }
5643
5644    #[test]
5645    fn c002_history_is_append_only() {
5646        let (tmp, store) = make_store();
5647        write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
5648        let _ = tmp;
5649        let mut issue = store.get(IssueSelector::Number(1)).unwrap();
5650        issue
5651            .history
5652            .append_now("alice", HistoryEventKind::RelationshipAdded, "first");
5653        let after_first = issue.history.clone();
5654        issue
5655            .history
5656            .append_now("alice", HistoryEventKind::RelationshipRemoved, "second");
5657
5658        assert_eq!(issue.history.len(), 2, "appending never replaces");
5659        assert_eq!(
5660            &issue.history.events()[..1],
5661            after_first.events(),
5662            "the earlier entry is untouched and still first"
5663        );
5664    }
5665
5666    #[test]
5667    fn c002_an_unknown_history_kind_is_kept_verbatim_not_coerced() {
5668        // The old version of this test asserted `contains("bogus_kind")` against
5669        // an error message that *echoes the offending line*, so it passed for any
5670        // input mentioning the word. It also fed ASCII hyphens, so
5671        // `splitn(4, U+2014)` never split and the real failure was a missing
5672        // field — the kind was never checked at all. Neither defect is a
5673        // hypothetical: the assertion was satisfied by the echo.
5674        //
5675        // Fixed on both counts. The line uses real em dashes, so the parser
5676        // genuinely reaches the kind field, and the assertion is on the parsed
5677        // outcome rather than on a substring of an error string.
5678        let line = "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} something";
5679        let raw = format!(
5680            "---\nnumber: 1\ntitle: \"H\"\nstatus: \"todo\"\n---\n\n## History\n\n{line}\n"
5681        );
5682        let issue = Issue::parse(&raw).expect("an unknown kind must not fail the parse");
5683
5684        assert!(
5685            issue.history.events().is_empty(),
5686            "no event was fabricated for an unknown kind"
5687        );
5688        assert_eq!(
5689            issue.history.unparsed(),
5690            vec![line],
5691            "the line is kept verbatim: not dropped, not repaired"
5692        );
5693        // Idempotence, not equality with the hand-written input: that input
5694        // omits keys serde always emits, so only the rendered form is a
5695        // meaningful byte-stability assertion.
5696        let rendered = issue.to_file_contents();
5697        assert!(rendered.contains(line), "the line survives the write");
5698        assert_eq!(
5699            Issue::parse(&rendered).unwrap().to_file_contents(),
5700            rendered,
5701            "and the file round-trips byte-identically"
5702        );
5703    }
5704
5705    #[test]
5706    fn list_mixes_grouped_and_flat_issues_sorted_by_relative_filename() {
5707        let (tmp, store) = make_store();
5708        let _ = tmp;
5709        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5710        fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
5711        write_issue(
5712            &store,
5713            "alice/2026-01-01-a.md",
5714            &sample_frontmatter(1, "A", "todo"),
5715        );
5716        write_issue(
5717            &store,
5718            "bob/2026-05-10-b.md",
5719            &sample_frontmatter(2, "B", "todo"),
5720        );
5721        write_issue(
5722            &store,
5723            "2026-02-01-c.md",
5724            &sample_frontmatter(3, "C", "todo"),
5725        );
5726        let issues = store.list().unwrap();
5727        let filenames: Vec<&str> = issues.iter().map(|i| i.filename.as_str()).collect();
5728        assert_eq!(issues.len(), 3);
5729        assert_eq!(
5730            filenames,
5731            vec![
5732                "2026-02-01-c.md",
5733                "alice/2026-01-01-a.md",
5734                "bob/2026-05-10-b.md"
5735            ],
5736            "deterministic sort by relative filename"
5737        );
5738    }
5739
5740    #[test]
5741    fn list_sorts_chronologically_within_a_group() {
5742        let (tmp, store) = make_store();
5743        let _ = tmp;
5744        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5745        write_issue(
5746            &store,
5747            "alice/2026-03-01-c.md",
5748            &sample_frontmatter(3, "C", "todo"),
5749        );
5750        write_issue(
5751            &store,
5752            "alice/2026-01-01-a.md",
5753            &sample_frontmatter(1, "A", "todo"),
5754        );
5755        write_issue(
5756            &store,
5757            "alice/2026-02-01-b.md",
5758            &sample_frontmatter(2, "B", "todo"),
5759        );
5760        let issues = store.list().unwrap();
5761        let numbers: Vec<u32> = issues.iter().map(|i| i.number).collect();
5762        assert_eq!(numbers, vec![1, 2, 3]);
5763    }
5764
5765    #[test]
5766    fn list_ignores_non_md_files_in_segments() {
5767        let (tmp, store) = make_store();
5768        let _ = tmp;
5769        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5770        write_issue(
5771            &store,
5772            "alice/2026-01-01-a.md",
5773            &sample_frontmatter(1, "A", "todo"),
5774        );
5775        fs::write(store.issues_dir.join("alice/notes.txt"), "not an issue").unwrap();
5776        fs::write(store.issues_dir.join("alice/.tix-tmp-123-456"), "orphan").unwrap();
5777        let issues = store.list().unwrap();
5778        assert_eq!(issues.len(), 1);
5779        assert_eq!(issues[0].filename, "alice/2026-01-01-a.md");
5780    }
5781
5782    #[test]
5783    fn list_skips_temp_marker_directories() {
5784        let (tmp, store) = make_store();
5785        let _ = tmp;
5786        fs::create_dir_all(store.issues_dir.join(".tix-tmp-999-0")).unwrap();
5787        write_issue(
5788            &store,
5789            ".tix-tmp-999-0/2026-01-01-junk.md",
5790            &sample_frontmatter(9, "J", "todo"),
5791        );
5792        write_issue(
5793            &store,
5794            "2026-01-01-a.md",
5795            &sample_frontmatter(1, "A", "todo"),
5796        );
5797        let issues = store.list().unwrap();
5798        assert_eq!(issues.len(), 1);
5799        assert_eq!(issues[0].filename, "2026-01-01-a.md");
5800    }
5801
5802    #[test]
5803    fn list_parse_error_carries_grouped_relative_path() {
5804        let (tmp, store) = make_store();
5805        let _ = tmp;
5806        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5807        fs::write(
5808            store.issues_dir.join("alice/2026-01-01-bad.md"),
5809            "no frontmatter here\n",
5810        )
5811        .unwrap();
5812        let err = store.list().unwrap_err();
5813        assert!(
5814            matches!(&err, TixError::Parse(m) if m.starts_with("alice/2026-01-01-bad.md:")),
5815            "parse error must carry the relative path, got {err:?}"
5816        );
5817    }
5818
5819    // ------------------------------------------------------------------
5820    // T007 — grouped create writes into the assignee segment (FR-029/30)
5821    // ------------------------------------------------------------------
5822
5823    #[test]
5824    fn create_writes_into_assignee_segment_and_records_relative_filename() {
5825        let (tmp, store) = make_store();
5826        let _ = tmp;
5827        let mut issue = Issue {
5828            number: 0,
5829            title: "T".into(),
5830            status: Status::default(),
5831            labels: vec![],
5832            priority: Priority::default(),
5833            assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
5834            estimate: None,
5835            commit: None,
5836            gitlab_id: None,
5837            linear_id: None,
5838            mission: None,
5839            task: None,
5840            relationships: vec![],
5841            files: vec![],
5842            cycles: None,
5843            milestones: None,
5844            reviewer: None,
5845            start_date: None,
5846            due_date: None,
5847            created: "2026-01-01".into(),
5848            updated: "2026-01-01".into(),
5849            filename: String::new(),
5850            body: String::new(),
5851            history: crate::model::History::new(),
5852            has_retired_links_key: false,
5853            parent: None,
5854            extra_frontmatter: std::collections::BTreeMap::new(),
5855            archived: false,
5856            entered_at: None,
5857            source: None,
5858        };
5859        let path = store
5860            .create(&mut issue, "body", Some("custom-stem"))
5861            .unwrap();
5862        let rel = "herbert-maschke/custom-stem.md";
5863        assert_eq!(issue.filename, rel);
5864        assert_eq!(path, store.issues_dir.join(rel));
5865        assert!(store.issues_dir.join("herbert-maschke").is_dir());
5866        assert!(store.issues_dir.join(rel).is_file());
5867    }
5868
5869    #[test]
5870    fn create_without_assignee_writes_unassigned_segment() {
5871        let (tmp, store) = make_store();
5872        let _ = tmp;
5873        let mut issue = Issue {
5874            number: 0,
5875            title: "T".into(),
5876            status: Status::default(),
5877            labels: vec![],
5878            priority: Priority::default(),
5879            assignee: None,
5880            estimate: None,
5881            commit: None,
5882            gitlab_id: None,
5883            linear_id: None,
5884            mission: None,
5885            task: None,
5886            relationships: vec![],
5887            files: vec![],
5888            cycles: None,
5889            milestones: None,
5890            reviewer: None,
5891            start_date: None,
5892            due_date: None,
5893            created: "2026-01-01".into(),
5894            updated: "2026-01-01".into(),
5895            filename: String::new(),
5896            body: String::new(),
5897            history: crate::model::History::new(),
5898            has_retired_links_key: false,
5899            parent: None,
5900            extra_frontmatter: std::collections::BTreeMap::new(),
5901            archived: false,
5902            entered_at: None,
5903            source: None,
5904        };
5905        let path = store.create(&mut issue, "", Some("stem")).unwrap();
5906        assert_eq!(issue.filename, "unassigned/stem.md");
5907        assert!(store.issues_dir.join("unassigned").is_dir());
5908        assert!(store.issues_dir.join("unassigned/stem.md").is_file());
5909        assert_eq!(path, store.issues_dir.join("unassigned/stem.md"));
5910    }
5911
5912    #[test]
5913    fn create_with_at_handle_derives_bare_segment() {
5914        let (tmp, store) = make_store();
5915        let _ = tmp;
5916        let mut issue = Issue {
5917            number: 0,
5918            title: "T".into(),
5919            status: Status::default(),
5920            labels: vec![],
5921            priority: Priority::default(),
5922            assignee: Some("@alice".into()),
5923            estimate: None,
5924            commit: None,
5925            gitlab_id: None,
5926            linear_id: None,
5927            mission: None,
5928            task: None,
5929            relationships: vec![],
5930            files: vec![],
5931            cycles: None,
5932            milestones: None,
5933            reviewer: None,
5934            start_date: None,
5935            due_date: None,
5936            created: "2026-01-01".into(),
5937            updated: "2026-01-01".into(),
5938            filename: String::new(),
5939            body: String::new(),
5940            history: crate::model::History::new(),
5941            has_retired_links_key: false,
5942            parent: None,
5943            extra_frontmatter: std::collections::BTreeMap::new(),
5944            archived: false,
5945            entered_at: None,
5946            source: None,
5947        };
5948        store.create(&mut issue, "", Some("stem")).unwrap();
5949        assert_eq!(issue.filename, "alice/stem.md");
5950        assert!(store.issues_dir.join("alice/stem.md").is_file());
5951    }
5952
5953    // ------------------------------------------------------------------
5954    // T008 — reassign(): move-on-reassign (FR-031), never overwrites
5955    // ------------------------------------------------------------------
5956
5957    #[test]
5958    fn reassign_moves_file_between_segments() {
5959        let (tmp, store) = make_store();
5960        let _ = tmp;
5961        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5962        write_issue(
5963            &store,
5964            "alice/2026-01-01-x.md",
5965            &sample_frontmatter(1, "X", "todo"),
5966        );
5967        let new_path = store
5968            .reassign("alice/2026-01-01-x.md", Some("@bob"))
5969            .unwrap();
5970        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x.md"));
5971        assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
5972        assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
5973    }
5974
5975    #[test]
5976    fn reassign_into_occupied_segment_picks_deterministic_suffix() {
5977        let (tmp, store) = make_store();
5978        let _ = tmp;
5979        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5980        fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
5981        write_issue(
5982            &store,
5983            "alice/2026-01-01-x.md",
5984            &sample_frontmatter(1, "X", "todo"),
5985        );
5986        write_issue(
5987            &store,
5988            "bob/2026-01-01-x.md",
5989            &sample_frontmatter(2, "X", "todo"),
5990        );
5991        let new_path = store
5992            .reassign("alice/2026-01-01-x.md", Some("@bob"))
5993            .unwrap();
5994        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x-1.md"));
5995        assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
5996        assert!(store.issues_dir.join("bob/2026-01-01-x-1.md").is_file());
5997        assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
5998    }
5999
6000    #[test]
6001    fn reassign_legacy_flat_file_into_segment() {
6002        let (tmp, store) = make_store();
6003        let _ = tmp;
6004        write_issue(
6005            &store,
6006            "2026-01-01-y.md",
6007            &sample_frontmatter(1, "Y", "todo"),
6008        );
6009        let new_path = store.reassign("2026-01-01-y.md", Some("alice")).unwrap();
6010        assert_eq!(new_path, PathBuf::from("alice/2026-01-01-y.md"));
6011        assert!(store.issues_dir.join("alice/2026-01-01-y.md").is_file());
6012        assert!(!store.issues_dir.join("2026-01-01-y.md").exists());
6013    }
6014
6015    #[test]
6016    fn reassign_to_none_uses_unassigned_fallback() {
6017        let (tmp, store) = make_store();
6018        let _ = tmp;
6019        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6020        write_issue(
6021            &store,
6022            "alice/2026-01-01-z.md",
6023            &sample_frontmatter(1, "Z", "todo"),
6024        );
6025        let new_path = store.reassign("alice/2026-01-01-z.md", None).unwrap();
6026        assert_eq!(new_path, PathBuf::from("unassigned/2026-01-01-z.md"));
6027        assert!(
6028            store
6029                .issues_dir
6030                .join("unassigned/2026-01-01-z.md")
6031                .is_file()
6032        );
6033        assert!(!store.issues_dir.join("alice/2026-01-01-z.md").exists());
6034    }
6035
6036    #[test]
6037    fn reassign_keeps_number_and_created_stable() {
6038        let (tmp, store) = make_store();
6039        let _ = tmp;
6040        let mut issue = Issue {
6041            number: 0,
6042            title: "Stable".into(),
6043            status: Status::default(),
6044            labels: vec![],
6045            priority: Priority::default(),
6046            assignee: Some("alice".into()),
6047            estimate: None,
6048            commit: None,
6049            gitlab_id: None,
6050            linear_id: None,
6051            mission: None,
6052            task: None,
6053            relationships: vec![],
6054            files: vec![],
6055            cycles: None,
6056            milestones: None,
6057            reviewer: None,
6058            start_date: None,
6059            due_date: None,
6060            created: "2026-01-01".into(),
6061            updated: "2026-01-01".into(),
6062            filename: String::new(),
6063            body: String::new(),
6064            history: crate::model::History::new(),
6065            has_retired_links_key: false,
6066            parent: None,
6067            extra_frontmatter: std::collections::BTreeMap::new(),
6068            archived: false,
6069            entered_at: None,
6070            source: None,
6071        };
6072        store.create(&mut issue, "body", None).unwrap();
6073        let number = issue.number;
6074        let created = issue.created.clone();
6075        let before = issue.filename.clone();
6076        let new_path = store.reassign(&before, Some("@bob")).unwrap();
6077        let rescan = store.list().unwrap();
6078        let moved = rescan
6079            .iter()
6080            .find(|i| i.filename == new_path)
6081            .expect("moved file must be listed at its new relative path");
6082        assert_eq!(moved.number, number);
6083        assert_eq!(moved.created, created);
6084        assert_eq!(moved.title, "Stable");
6085    }
6086
6087    #[test]
6088    fn reassign_equal_segment_is_noop() {
6089        let (tmp, store) = make_store();
6090        let _ = tmp;
6091        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6092        write_issue(
6093            &store,
6094            "alice/2026-01-01-x.md",
6095            &sample_frontmatter(1, "X", "todo"),
6096        );
6097        let new_path = store
6098            .reassign("alice/2026-01-01-x.md", Some("alice"))
6099            .unwrap();
6100        assert_eq!(new_path, PathBuf::from("alice/2026-01-01-x.md"));
6101        assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
6102    }
6103
6104    #[test]
6105    fn reassign_missing_file_is_not_found() {
6106        let (tmp, store) = make_store();
6107        let _ = tmp;
6108        let err = store.reassign("nope.md", Some("alice")).unwrap_err();
6109        assert!(matches!(err, TixError::NotFound(_)));
6110    }
6111
6112    // ------------------------------------------------------------------
6113    // T009 — validate_filename relaxation + relative-path ops (C-008/C-010)
6114    // ------------------------------------------------------------------
6115
6116    #[test]
6117    fn validate_filename_allows_flat_and_single_segment_prefix() {
6118        validate_filename("2026-01-01-x.md").unwrap();
6119        validate_filename("alice/2026-01-01-x.md").unwrap();
6120    }
6121
6122    #[test]
6123    fn validate_filename_refuses_deeper_segments() {
6124        let err = validate_filename("alice/bob/2026-01-01-x.md").unwrap_err();
6125        assert!(matches!(err, TixError::InvalidInput(_)));
6126    }
6127
6128    #[test]
6129    fn validate_filename_refuses_leading_and_trailing_slash_and_absolute() {
6130        let err = validate_filename("/alice/2026-01-01-x.md").unwrap_err();
6131        assert!(matches!(err, TixError::InvalidInput(_)));
6132        let err = validate_filename("/etc/passwd").unwrap_err();
6133        assert!(matches!(err, TixError::InvalidInput(_)));
6134        let err = validate_filename("alice/").unwrap_err();
6135        assert!(matches!(err, TixError::InvalidInput(_)));
6136    }
6137
6138    #[test]
6139    fn path_traversal_with_segment_dotdot_refused() {
6140        let err = validate_filename("alice/../../x.md").unwrap_err();
6141        assert!(matches!(err, TixError::InvalidInput(_)));
6142        let err = validate_filename("alice/../x.md").unwrap_err();
6143        assert!(matches!(err, TixError::InvalidInput(_)));
6144    }
6145
6146    #[test]
6147    fn get_update_delete_set_status_work_on_grouped_relative_paths() {
6148        let (tmp, store) = make_store();
6149        let _ = tmp;
6150        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6151        write_issue(
6152            &store,
6153            "alice/2026-01-01-g.md",
6154            &sample_frontmatter(7, "G", "todo"),
6155        );
6156        let got = store
6157            .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
6158            .unwrap();
6159        assert_eq!(got.number, 7);
6160        let done = store
6161            .set_status("alice/2026-01-01-g.md", StatusChange::to("done"))
6162            .unwrap();
6163        assert_eq!(done.issue.status.display(), "done");
6164        assert!(store.issues_dir.join("alice/2026-01-01-g.md").is_file());
6165        let mut edited = store
6166            .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
6167            .unwrap();
6168        edited.title = "Updated".into();
6169        store.update("alice/2026-01-01-g.md", &edited).unwrap();
6170        let reloaded = store
6171            .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
6172            .unwrap();
6173        assert_eq!(reloaded.title, "Updated");
6174        store.delete("alice/2026-01-01-g.md").unwrap();
6175        assert!(!store.issues_dir.join("alice/2026-01-01-g.md").exists());
6176    }
6177
6178    // ------------------------------------------------------------------
6179    // T010 — grouped round-trip sweep
6180    // ------------------------------------------------------------------
6181
6182    #[test]
6183    fn grouped_round_trip_create_list_get_update_delete() {
6184        let (tmp, store) = make_store();
6185        let _ = tmp;
6186        let mut issue = Issue {
6187            number: 0,
6188            title: "Round trip".into(),
6189            status: Status::default(),
6190            labels: vec!["bug".into()],
6191            priority: Priority::default(),
6192            assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
6193            estimate: None,
6194            commit: None,
6195            gitlab_id: None,
6196            linear_id: None,
6197            mission: None,
6198            task: None,
6199            relationships: vec![],
6200            files: vec![],
6201            cycles: None,
6202            milestones: None,
6203            reviewer: None,
6204            start_date: None,
6205            due_date: None,
6206            created: "2026-01-01".into(),
6207            updated: "2026-01-01".into(),
6208            filename: String::new(),
6209            body: String::new(),
6210            history: crate::model::History::new(),
6211            has_retired_links_key: false,
6212            parent: None,
6213            extra_frontmatter: std::collections::BTreeMap::new(),
6214            archived: false,
6215            entered_at: None,
6216            source: None,
6217        };
6218        store.create(&mut issue, "initial body", None).unwrap();
6219        let rel = issue.filename.clone();
6220        assert_eq!(rel, "herbert-maschke/2026-01-01-round-trip.md");
6221
6222        let listed = store.list().unwrap();
6223        assert_eq!(listed.len(), 1);
6224        assert_eq!(listed[0].filename, rel);
6225        assert_eq!(listed[0].number, issue.number);
6226
6227        let got = store.get(IssueSelector::Filename(rel.clone())).unwrap();
6228        let mut edited = got.clone();
6229        edited.title = "Renamed".into();
6230        store.update(&rel, &edited).unwrap();
6231        let reloaded = store.get(IssueSelector::Filename(rel.clone())).unwrap();
6232        assert_eq!(reloaded.title, "Renamed");
6233
6234        store.delete(&rel).unwrap();
6235        assert!(!store.issues_dir.join(&rel).exists());
6236        assert!(store.list().unwrap().is_empty());
6237    }
6238
6239    #[test]
6240    fn legacy_flat_file_edit_and_status_still_work_after_layout_flip() {
6241        let (tmp, store) = make_store();
6242        let _ = tmp;
6243        write_issue(
6244            &store,
6245            "2026-01-01-legacy.md",
6246            &sample_frontmatter(5, "Legacy", "todo"),
6247        );
6248        let listed = store.list().unwrap();
6249        assert_eq!(listed.len(), 1);
6250        assert_eq!(listed[0].filename, "2026-01-01-legacy.md");
6251        let mutated = store
6252            .set_status("2026-01-01-legacy.md", StatusChange::to("in progress"))
6253            .unwrap();
6254        assert_eq!(mutated.issue.status.display(), "in progress");
6255        assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
6256    }
6257
6258    // ------------------------------------------------------------------
6259    // T004 — add_asset(): copy + link insert (FR-006, NFR-004)
6260    // ------------------------------------------------------------------
6261
6262    fn write_asset_source(tmp: &tempfile::TempDir, name: &str, contents: &[u8]) -> PathBuf {
6263        let path = tmp.path().join(name);
6264        fs::write(&path, contents).unwrap();
6265        path
6266    }
6267
6268    fn write_issue_with_basic_body(store: &Store, name: &str, number: u32) {
6269        write_issue(
6270            store,
6271            name,
6272            &sample_frontmatter(number, "Asset host", "todo"),
6273        );
6274    }
6275
6276    #[test]
6277    fn add_asset_image_inserts_image_link_and_writes_file() {
6278        let (tmp, store) = make_store();
6279        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6280        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6281        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6282        let added = store
6283            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6284            .unwrap();
6285        assert_eq!(added.written_name, "diagram.png");
6286        assert_eq!(added.link, "![diagram](fix-login-bug-assets/diagram.png)");
6287        assert!(
6288            store
6289                .issues_dir
6290                .join("alice/fix-login-bug-assets/diagram.png")
6291                .is_file()
6292        );
6293    }
6294
6295    #[test]
6296    fn add_asset_non_image_inserts_generic_markdown_link() {
6297        let (tmp, store) = make_store();
6298        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6299        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6300        let src = write_asset_source(&tmp, "report.pdf", b"%PDF");
6301        let added = store
6302            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6303            .unwrap();
6304        assert_eq!(added.link, "[report](fix-login-bug-assets/report.pdf)");
6305        assert!(
6306            store
6307                .issues_dir
6308                .join("alice/fix-login-bug-assets/report.pdf")
6309                .is_file()
6310        );
6311    }
6312
6313    #[test]
6314    fn add_asset_collision_resolves_with_numeric_suffix_and_link_points_at_it() {
6315        let (tmp, store) = make_store();
6316        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6317        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6318        let src = write_asset_source(&tmp, "x.png", b"first");
6319        let first = store
6320            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6321            .unwrap();
6322        assert_eq!(first.written_name, "x.png");
6323        let again = store
6324            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6325            .unwrap();
6326        assert_eq!(again.written_name, "x-1.png");
6327        assert_eq!(again.link, "![x-1](fix-login-bug-assets/x-1.png)");
6328        assert!(
6329            store
6330                .issues_dir
6331                .join("alice/fix-login-bug-assets/x.png")
6332                .is_file()
6333        );
6334        assert!(
6335            store
6336                .issues_dir
6337                .join("alice/fix-login-bug-assets/x-1.png")
6338                .is_file()
6339        );
6340    }
6341
6342    #[test]
6343    fn add_asset_appends_single_link_and_preserves_description() {
6344        let (tmp, store) = make_store();
6345        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6346        write_issue(
6347            &store,
6348            "alice/2026-01-01-fix-login-bug.md",
6349            "---\nnumber: 1\ntitle: \"H\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nTitle line.\n\n## Comments\n\n### alice (2026-01-01)\n\nExisting comment.\n",
6350        );
6351        let src = write_asset_source(&tmp, "diagram.svg", b"<svg/>");
6352        store
6353            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6354            .unwrap();
6355        let reloaded = store
6356            .get(crate::storage::IssueSelector::Filename(
6357                "alice/2026-01-01-fix-login-bug.md".into(),
6358            ))
6359            .unwrap();
6360        // Description (above the `## Comments` boundary) untouched.
6361        assert_eq!(reloaded.description(), "Title line.");
6362        // Exactly one link line, appended after the comments section.
6363        let link_count = reloaded.body.matches("![diagram]").count();
6364        assert_eq!(link_count, 1);
6365        assert!(
6366            reloaded
6367                .body
6368                .ends_with("fix-login-bug-assets/diagram.svg)\n")
6369        );
6370    }
6371
6372    #[test]
6373    fn add_asset_missing_issue_is_not_found() {
6374        let (tmp, store) = make_store();
6375        let _ = tmp;
6376        let src = write_asset_source(&tmp, "x.png", b"x");
6377        let err = store.add_asset("missing.md", &src).unwrap_err();
6378        assert!(matches!(err, TixError::NotFound(_)));
6379    }
6380
6381    #[test]
6382    fn add_asset_legacy_flat_file_uses_stem_assets_under_issues_dir() {
6383        let (tmp, store) = make_store();
6384        write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
6385        let src = write_asset_source(&tmp, "x.png", b"x");
6386        let added = store.add_asset("2026-01-01-legacy.md", &src).unwrap();
6387        assert_eq!(added.link, "![x](legacy-assets/x.png)");
6388        assert!(store.issues_dir.join("legacy-assets/x.png").is_file());
6389    }
6390
6391    // ------------------------------------------------------------------
6392    // T005 — reassign() moves the assets dir (FR-007)
6393    // ------------------------------------------------------------------
6394
6395    #[test]
6396    fn reassign_moves_asset_dir_with_the_file() {
6397        let (tmp, store) = make_store();
6398        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6399        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6400        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6401        store
6402            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6403            .unwrap();
6404        let new_path = store
6405            .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
6406            .unwrap();
6407        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
6408        assert!(
6409            store
6410                .issues_dir
6411                .join("bob/fix-login-bug-assets/diagram.png")
6412                .is_file()
6413        );
6414        assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
6415    }
6416
6417    #[test]
6418    fn reassign_without_asset_dir_behaves_as_before() {
6419        let (tmp, store) = make_store();
6420        let _ = tmp;
6421        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6422        write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
6423        let new_path = store
6424            .reassign("alice/2026-01-01-plain.md", Some("@bob"))
6425            .unwrap();
6426        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-plain.md"));
6427        assert!(store.issues_dir.join("bob/2026-01-01-plain.md").is_file());
6428        assert!(!store.issues_dir.join("bob/plain-assets").exists());
6429    }
6430
6431    #[test]
6432    fn reassign_into_segment_with_same_named_asset_dir_uses_suffix_no_loss() {
6433        let (tmp, store) = make_store();
6434        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6435        fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
6436        // Both segments already hold a same-named asset dir.
6437        let existing = store.issues_dir.join("bob/fix-login-bug-assets");
6438        fs::create_dir_all(&existing).unwrap();
6439        fs::write(existing.join("keep.txt"), "precious").unwrap();
6440        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6441        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6442        store
6443            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6444            .unwrap();
6445        let new_path = store
6446            .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
6447            .unwrap();
6448        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
6449        // Pre-existing target assets untouched, moved dir suffixed.
6450        assert!(existing.join("keep.txt").is_file());
6451        assert!(
6452            store
6453                .issues_dir
6454                .join("bob/fix-login-bug-assets-1/diagram.png")
6455                .is_file()
6456        );
6457        assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
6458    }
6459
6460    #[test]
6461    fn reassign_preserves_markdown_link_resolution_in_new_segment() {
6462        let (tmp, store) = make_store();
6463        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6464        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6465        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6466        store
6467            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6468            .unwrap();
6469        store
6470            .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
6471            .unwrap();
6472        // The asset dir kept its base name, so the relative link
6473        // `fix-login-bug-assets/diagram.png` resolves in the new segment.
6474        assert!(
6475            store
6476                .issues_dir
6477                .join("bob/fix-login-bug-assets/diagram.png")
6478                .is_file()
6479        );
6480    }
6481
6482    // ------------------------------------------------------------------
6483    // T006 — delete() removes the assets dir (FR-008) + sweep
6484    // ------------------------------------------------------------------
6485
6486    #[test]
6487    fn delete_removes_asset_dir_no_orphans() {
6488        let (tmp, store) = make_store();
6489        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6490        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6491        let src = write_asset_source(&tmp, "x.png", b"x");
6492        store
6493            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6494            .unwrap();
6495        store.delete("alice/2026-01-01-fix-login-bug.md").unwrap();
6496        assert!(
6497            !store
6498                .issues_dir
6499                .join("alice/2026-01-01-fix-login-bug.md")
6500                .exists()
6501        );
6502        assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
6503    }
6504
6505    #[test]
6506    fn delete_without_asset_dir_still_deletes_file() {
6507        let (tmp, store) = make_store();
6508        let _ = tmp;
6509        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6510        write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
6511        store.delete("alice/2026-01-01-plain.md").unwrap();
6512        assert!(!store.issues_dir.join("alice/2026-01-01-plain.md").exists());
6513    }
6514
6515    #[test]
6516    fn list_ignores_asset_directories() {
6517        let (tmp, store) = make_store();
6518        write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
6519        let src = write_asset_source(&tmp, "x.png", b"x");
6520        store.add_asset("2026-01-01-legacy.md", &src).unwrap();
6521        // Legacy flat file → `legacy-assets/` sits directly under issues_dir,
6522        // where the `-assets` guard in `list()` must skip it.
6523        let issues = store.list().unwrap();
6524        assert_eq!(issues.len(), 1);
6525        assert_eq!(issues[0].filename, "2026-01-01-legacy.md");
6526    }
6527
6528    #[test]
6529    fn list_ignores_nested_asset_directories_under_segment() {
6530        let (tmp, store) = make_store();
6531        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6532        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6533        let src = write_asset_source(&tmp, "x.png", b"x");
6534        store
6535            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6536            .unwrap();
6537        let issues = store.list().unwrap();
6538        assert_eq!(issues.len(), 1);
6539        assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
6540    }
6541
6542    #[test]
6543    fn asset_dir_with_formless_md_child_is_not_scanned_as_segment() {
6544        let (tmp, store) = make_store();
6545        let _ = tmp;
6546        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6547        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6548        // A `.md` straggler inside an `-assets` dir must never surface as an
6549        // issue segment (guard in `list()` for `<slug>-assets/`).
6550        let assets_dir = store.issues_dir.join("alice/fix-login-bug-assets");
6551        fs::create_dir_all(&assets_dir).unwrap();
6552        fs::write(assets_dir.join("notes.md"), "no frontmatter").unwrap();
6553        let issues = store.list().unwrap();
6554        assert_eq!(issues.len(), 1);
6555        assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
6556    }
6557
6558    // -----------------------------------------------------------------------
6559    // WP02 / T008 — hierarchy writes
6560    // -----------------------------------------------------------------------
6561    //
6562    // Requirement ids in the test names: C-001 (at most one parent), C-003 (no
6563    // child data on the parent), C-004 (every change audited), SC-004 (a rejected
6564    // change writes nothing).
6565    //
6566    // These tests assert the *write* behaviour only. Every rule lives in
6567    // `crate::hierarchy`; the last test below is the guard against one of them
6568    // being copied in here.
6569
6570    /// Three seeded issues, for the hierarchy write tests.
6571    fn hierarchy_trio() -> (TempDir, Store) {
6572        let (tmp, store) = make_store();
6573        for (n, title) in [(1, "A"), (2, "B"), (3, "C")] {
6574            write_issue(
6575                &store,
6576                &format!("2026-01-01-{}.md", title.to_lowercase()),
6577                &bare_frontmatter(n, title),
6578            );
6579        }
6580        (tmp, store)
6581    }
6582
6583    /// A store over one checked-in hierarchy fixture, by file name.
6584    ///
6585    /// `tests/fixtures/hierarchy/` rather than a string built inline, because the
6586    /// interesting cases are exactly the states the command layer refuses to
6587    /// create: a self-parent, a dangling reference. Those can only be reached by
6588    /// writing the file, which is the point of the fixtures.
6589    fn store_from_hierarchy_fixture(name: &str) -> (TempDir, Store) {
6590        let (tmp, store) = make_store();
6591        write_issue(
6592            &store,
6593            name,
6594            &std::fs::read_to_string(format!(
6595                "{}/tests/fixtures/hierarchy/{name}",
6596                env!("CARGO_MANIFEST_DIR")
6597            ))
6598            .unwrap(),
6599        );
6600        (tmp, store)
6601    }
6602
6603    fn bytes_of(store: &Store, number: u32) -> String {
6604        let issue = store.get(IssueSelector::Number(number)).unwrap();
6605        read_issue_file(store, &issue.filename)
6606    }
6607
6608    #[test]
6609    fn t008_set_writes_the_field_and_exactly_one_history_event() {
6610        let (_tmp, store) = hierarchy_trio();
6611        let outcome = store.set_parent("2", "1").unwrap();
6612        assert!(outcome.changed);
6613        assert_eq!(outcome.parent, Some(1));
6614
6615        let child = store.get(IssueSelector::Number(2)).unwrap();
6616        assert_eq!(child.parent, Some(1), "C-001: the number is stored");
6617        assert_eq!(child.history.len(), 1, "C-004: exactly one event");
6618        let event = &child.history.events()[0];
6619        assert_eq!(event.kind, HistoryEventKind::ParentSet);
6620        assert!(
6621            !event.actor.is_empty(),
6622            "an event without an actor is not an audit record"
6623        );
6624        // The detail names both sides, read from the *previous* value.
6625        assert_eq!(event.detail, "parent none -> #1", "got: {}", event.detail);
6626    }
6627
6628    #[test]
6629    fn t008_change_repoints_and_records_parent_changed() {
6630        let (_tmp, store) = hierarchy_trio();
6631        store.set_parent("3", "1").unwrap();
6632        let outcome = store.change_parent("3", "2").unwrap();
6633        assert!(outcome.changed);
6634        assert_eq!(outcome.parent, Some(2));
6635
6636        let child = store.get(IssueSelector::Number(3)).unwrap();
6637        assert_eq!(child.parent, Some(2));
6638        assert_eq!(child.history.len(), 2, "one event per accepted change");
6639        let events = child.history.events();
6640        let last = events.last().unwrap();
6641        assert_eq!(last.kind, HistoryEventKind::ParentChanged);
6642        // Regression: the detail used to be built after the assignment, so a
6643        // repoint recorded `parent #1 -> #1` and named neither side.
6644        assert_eq!(last.detail, "parent #1 -> #2", "got: {}", last.detail);
6645    }
6646
6647    #[test]
6648    fn t008_clear_removes_the_field_and_records_parent_cleared() {
6649        let (_tmp, store) = hierarchy_trio();
6650        store.set_parent("2", "1").unwrap();
6651        let outcome = store.clear_parent("2").unwrap();
6652        assert!(outcome.changed);
6653        assert_eq!(outcome.parent, None);
6654
6655        let child = store.get(IssueSelector::Number(2)).unwrap();
6656        assert_eq!(child.parent, None);
6657        assert_eq!(child.history.len(), 2);
6658        let events = child.history.events();
6659        let last = events.last().unwrap();
6660        assert_eq!(last.kind, HistoryEventKind::ParentCleared);
6661        assert_eq!(last.detail, "parent #1 -> none", "got: {}", last.detail);
6662    }
6663
6664    #[test]
6665    fn t008_reapplying_the_same_parent_is_a_no_op_with_no_second_event() {
6666        let (_tmp, store) = hierarchy_trio();
6667        store.set_parent("2", "1").unwrap();
6668        let after_first = bytes_of(&store, 2);
6669
6670        let second = store.set_parent("2", "1").unwrap();
6671        assert!(!second.changed, "an unchanged parent is not a change");
6672        assert!(second.event.is_none(), "a no-op records nothing (C-004)");
6673        assert_eq!(
6674            bytes_of(&store, 2),
6675            after_first,
6676            "a no-op must not rewrite the file at all"
6677        );
6678    }
6679
6680    #[test]
6681    fn t008_clearing_a_child_with_no_parent_is_a_no_op() {
6682        let (_tmp, store) = hierarchy_trio();
6683        let before = bytes_of(&store, 2);
6684        let outcome = store.clear_parent("2").unwrap();
6685        assert!(!outcome.changed);
6686        assert!(outcome.event.is_none());
6687        assert_eq!(bytes_of(&store, 2), before);
6688    }
6689
6690    #[test]
6691    fn t008_sc004_a_rejected_cycle_leaves_every_file_byte_identical() {
6692        // 1 -> 2 -> 3, then ask 1 to become a child of 3: that closes the loop.
6693        let (_tmp, store) = hierarchy_trio();
6694        store.set_parent("2", "1").unwrap();
6695        store.set_parent("3", "2").unwrap();
6696        let before: Vec<String> = (1..=3).map(|n| bytes_of(&store, n)).collect();
6697
6698        let err = store
6699            .set_parent("1", "3")
6700            .expect_err("1 -> 2 -> 3 -> 1 is a cycle and must be refused");
6701        let msg = err.to_string();
6702        assert!(
6703            msg.contains("cycle"),
6704            "the diagnostic must name the rule: {msg}"
6705        );
6706
6707        // SC-004: nothing written, nothing appended, on *any* file.
6708        for n in 1..=3 {
6709            assert_eq!(
6710                bytes_of(&store, n),
6711                before[(n - 1) as usize],
6712                "issue {n} must be byte-identical after a rejected cycle"
6713            );
6714        }
6715    }
6716
6717    #[test]
6718    fn t008_a_self_parent_is_rejected_and_writes_nothing() {
6719        let (_tmp, store) = hierarchy_trio();
6720        let before = bytes_of(&store, 2);
6721        let err = store
6722            .set_parent("2", "2")
6723            .expect_err("an issue cannot be its own parent");
6724        assert!(err.to_string().contains("self_parent"), "got: {err}");
6725        assert_eq!(bytes_of(&store, 2), before);
6726    }
6727
6728    #[test]
6729    fn t008_a_self_parent_already_on_disk_is_still_refused() {
6730        // The laundering case, through the file path rather than the service.
6731        //
6732        // A child that *already* records itself as its own parent looks like a
6733        // redundant request, and a no-op check placed before validation accepted it
6734        // as one — while the identical request against any other child was refused.
6735        // Only a fixture in that state can catch it: the test above seeds a
6736        // parentless child, so the guard never fired.
6737        let (_tmp, store) = store_from_hierarchy_fixture("self-parent.md");
6738        let before = bytes_of(&store, 7);
6739        let err = store
6740            .set_parent("7", "7")
6741            .expect_err("a stored self-parent must still be refused on a re-set");
6742        assert!(
6743            err.to_string().contains("self_parent"),
6744            "refused for the wrong reason: {err}"
6745        );
6746        assert_eq!(
6747            bytes_of(&store, 7),
6748            before,
6749            "and the refused write must leave the file byte-identical"
6750        );
6751        // `change_parent` shares the path and must refuse identically.
6752        assert!(store.change_parent("7", "7").is_err());
6753        // The fixture really is in the state under test.
6754        assert_eq!(store.get(IssueSelector::Number(7)).unwrap().parent, Some(7));
6755    }
6756
6757    #[test]
6758    fn t008_c003_setting_a_child_writes_nothing_to_the_parents_file() {
6759        // Child membership is derived by inverting the child's pointer; the
6760        // parent file must not learn about it at all.
6761        let (_tmp, store) = hierarchy_trio();
6762        let parent_before = bytes_of(&store, 1);
6763        store.set_parent("2", "1").unwrap();
6764        store.set_parent("3", "1").unwrap();
6765        assert_eq!(
6766            bytes_of(&store, 1),
6767            parent_before,
6768            "the parent's file must be untouched by its children appearing"
6769        );
6770        assert!(
6771            !parent_before.contains("parent:"),
6772            "the parent is a top of the hierarchy and has no parent itself"
6773        );
6774    }
6775
6776    #[test]
6777    fn t008_a_legacy_file_without_the_field_is_unchanged_by_a_no_op_write() {
6778        // The file-format contract, seen through the write path.
6779        let (_tmp, store) = hierarchy_trio();
6780        let before = bytes_of(&store, 2);
6781        assert!(!before.contains("parent:"), "the fixture must be legacy");
6782
6783        store.clear_parent("2").unwrap();
6784        assert_eq!(bytes_of(&store, 2), before, "a no-op must not add the key");
6785    }
6786
6787    #[test]
6788    fn t008_an_unresolvable_selector_is_refused_before_any_write() {
6789        let (_tmp, store) = hierarchy_trio();
6790        let before = bytes_of(&store, 2);
6791        assert!(store.set_parent("2", "no such issue").is_err());
6792        assert!(store.change_parent("2", "999").is_err());
6793        assert_eq!(bytes_of(&store, 2), before);
6794    }
6795
6796    #[test]
6797    fn t008_t008_contains_no_hierarchy_rule_of_its_own() {
6798        // T008's own note: "Storage must contain no hierarchy *rules*. If a check
6799        // appears here that also exists in the service, that is a defect."
6800        //
6801        // Asserted behaviourally rather than by reading the source: a storage
6802        // rejection must be **exactly** what the service says for the same
6803        // snapshot. If someone re-implemented a rule here, the two would drift and
6804        // this comparison would stop holding.
6805        //
6806        // Compared against the service *type's own* rendering, reached through the
6807        // `From<HierarchyError> for TixError` conversion the call site uses. An
6808        // earlier version of this test compared storage against a private
6809        // `hierarchy_error` helper — both sides went through the same function, so
6810        // it certified only that the helper was called, and it could not detect the
6811        // duplication it was meant to police (a second renderer that had drifted
6812        // from the first).
6813        let (_tmp, store) = hierarchy_trio();
6814        store.set_parent("2", "1").unwrap();
6815        store.set_parent("3", "2").unwrap();
6816
6817        let issues = store.list().unwrap();
6818        let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
6819        let index = HierarchyIndex::build(&views);
6820        let service =
6821            hierarchy::set_parent(&index, 1, 3).expect_err("the service refuses this too");
6822        let storage = store.set_parent("1", "3").expect_err("and so does storage");
6823
6824        assert_eq!(
6825            storage.to_string(),
6826            TixError::from(service).to_string(),
6827            "storage must surface the service's diagnosis verbatim, not its own"
6828        );
6829        // And the rendering is the shared one, not a lookalike: the kind is
6830        // prefixed exactly once and no diagnostic is dropped.
6831        let text = storage.to_string();
6832        assert!(
6833            text.contains("cycle:") && !text.contains("cycle: cycle:"),
6834            "a lookalike renderer or a doubled prefix: {text}"
6835        );
6836        assert!(
6837            text.starts_with("invalid input: "),
6838            "a hierarchy refusal must carry the same prefix as every other: {text}"
6839        );
6840    }
6841
6842    #[test]
6843    fn t008_a_no_op_reports_no_written_issue_but_still_reports_the_current_parent() {
6844        // Pins the `ParentOutcome` contract, which its own doc comment used to
6845        // contradict: `written` is `None` for a no-op (matching
6846        // `RelationshipOutcome`), and the real state comes from `parent`.
6847        let (_tmp, store) = hierarchy_trio();
6848        store.set_parent("2", "1").unwrap();
6849        let again = store.set_parent("2", "1").unwrap();
6850        assert!(!again.changed);
6851        assert!(
6852            again.written.is_none(),
6853            "a no-op wrote nothing, so there is no written issue to hand back"
6854        );
6855        assert!(
6856            again.event.is_none(),
6857            "and no event, or a retry would pad the audit trail"
6858        );
6859        assert_eq!(
6860            again.parent,
6861            Some(1),
6862            "but the resulting state is still reported, so a caller need not re-read"
6863        );
6864    }
6865}