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