Skip to main content

tix/
storage.rs

1use std::collections::{BTreeSet, 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
14mod epics;
15mod lifecycle;
16mod parent;
17mod projects;
18mod query;
19mod relations;
20mod status;
21
22pub struct Store {
23    pub issues_dir: PathBuf,
24    pub config: Config,
25}
26
27/// The outcome of one relationship mutation.
28///
29/// A typed relationship is a **single directed edge**, so a mutation normally
30/// writes exactly one issue file — the one whose frontmatter owns the canonical
31/// edge. `written` is `None` when the mutation was a no-op (an
32/// already-present edge, or an absent edge on remove), which is what makes
33/// "an idempotent re-apply appends no second History event" checkable.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct RelationshipOutcome {
36    /// The issue whose file was written, if any.
37    pub written: Option<Issue>,
38    /// The relationship the mutation concerned, in canonical form.
39    pub relationship: Relationship,
40    /// Whether a file was actually written.
41    pub changed: bool,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum IssueSelector {
46    Number(u32),
47    Ref(crate::id::IssueRef),
48    Filename(String),
49    Slug(String),
50}
51
52/// Result of `Store::add_asset`: the relative markdown link inserted into the
53/// body and the actual written file name (collision-resolved).
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct AssetAdded {
56    pub link: String,
57    pub written_name: String,
58}
59
60// Disclosures raised by writes, drained by whichever surface is presenting.
61//
62// A disclosure is something the user must be told that is not an error: the removal of the
63// retired `links:` key, or a mention reconciliation that declined to touch a malformed target
64// file. It is queued rather than printed so every surface can render it in its own channel —
65// stderr for the CLI, the MCP result payload for a tool call, the status line for the TUI.
66//
67// Thread-local rather than process-global: a CLI and an MCP request each drain their own, and a
68// process-global queue made concurrent callers — and the test suite, which runs in parallel —
69// see each other's disclosures.
70thread_local! {
71    static WRITE_DISCLOSURES: std::cell::RefCell<Vec<String>> =
72        const { std::cell::RefCell::new(Vec::new()) };
73}
74
75/// Queue a disclosure for the presenting surface to render.
76pub(crate) fn push_disclosure(message: String) {
77    WRITE_DISCLOSURES.with(|queue| queue.borrow_mut().push(message));
78}
79
80/// A mention reconciliation declined to touch a target file, naming the reason.
81pub(crate) fn mention_disclosure(message: String) {
82    push_disclosure(message);
83}
84
85/// Record that a write removed the retired `links:` key from `filename`.
86/// `pub(crate)` so the surface drains in `app` and `mcp` can be tested against
87/// a raised disclosure. Production code reaches it only through `Store::update`.
88pub(crate) fn retired_key_disclosure(filename: &str) {
89    push_disclosure(format!(
90        "remove `links:` by hand to silence \u{2014} {filename} (retired by \
91         C-001; never read as typed data, no migration)"
92    ));
93}
94
95/// Take everything queued since the last drain.
96pub fn take_disclosures() -> Vec<String> {
97    WRITE_DISCLOSURES.with(|queue| std::mem::take(&mut *queue.borrow_mut()))
98}
99
100// A bounded journal of issue-file writes made through this store.
101//
102// The TUI's in-process writes used to re-read and parse the whole issue tree
103// just to learn about the one file the store had already written. The journal
104// closes that gap without changing the external-edit watcher: `refresh_corpus`
105// drains it into the same `FsDelta` path the watcher uses.
106//
107// Thread-local for the same reason as the disclosure queue: a CLI and an MCP
108// request each run on their own thread, and the test suite runs tests in
109// parallel. The journal is bounded. A long-lived MCP thread can write more than
110// the journal retains, but exceeding the bound switches the next refresh to the
111// full-rebuild fallback instead of growing without limit.
112const ISSUE_WRITE_JOURNAL_LIMIT: usize = 4096;
113
114#[derive(Debug, Default)]
115struct IssueWriteJournal {
116    changed: BTreeSet<PathBuf>,
117    removed: BTreeSet<PathBuf>,
118    overflowed: bool,
119}
120
121/// How the next in-process corpus refresh should get its changes.
122#[derive(Debug)]
123pub(crate) enum InternalIssueWrites {
124    /// No issue file was written through this thread's store.
125    None,
126    /// The exact paths to apply through the existing incremental reader.
127    Delta(crate::watcher::FsDelta),
128    /// More paths were written than the journal retains; rebuild from disk.
129    Overflow,
130}
131
132thread_local! {
133    static ISSUE_WRITE_JOURNAL: std::cell::RefCell<IssueWriteJournal> =
134        std::cell::RefCell::new(IssueWriteJournal::default());
135}
136
137/// Record one issue-file write or removal for the next in-process refresh.
138///
139/// Paths are store-relative, matching `Issue::filename` and `FsDelta`. A later
140/// observation wins: writing then moving one file leaves only the destination
141/// as changed and the source as removed.
142pub(crate) fn journal_issue_change(path: &Path, present: bool) {
143    if !is_issue_rel_path(path) {
144        return;
145    }
146    ISSUE_WRITE_JOURNAL.with(|journal| {
147        let mut journal = journal.borrow_mut();
148        if journal.overflowed {
149            return;
150        }
151        if present {
152            journal.removed.remove(path);
153            journal.changed.insert(path.to_path_buf());
154        } else {
155            journal.changed.remove(path);
156            journal.removed.insert(path.to_path_buf());
157        }
158        if journal.changed.len() + journal.removed.len() > ISSUE_WRITE_JOURNAL_LIMIT {
159            journal.changed.clear();
160            journal.removed.clear();
161            journal.overflowed = true;
162        }
163    });
164}
165
166/// Drain the paths written since the last in-process refresh.
167pub(crate) fn take_internal_issue_writes() -> InternalIssueWrites {
168    ISSUE_WRITE_JOURNAL.with(|journal| {
169        let mut journal = journal.borrow_mut();
170        if journal.overflowed {
171            *journal = IssueWriteJournal::default();
172            return InternalIssueWrites::Overflow;
173        }
174        if journal.changed.is_empty() && journal.removed.is_empty() {
175            return InternalIssueWrites::None;
176        }
177        let changed = std::mem::take(&mut journal.changed);
178        let removed = std::mem::take(&mut journal.removed);
179        InternalIssueWrites::Delta(crate::watcher::FsDelta {
180            changed: changed.into_iter().collect(),
181            removed: removed.into_iter().collect(),
182        })
183    })
184}
185
186// A command-level write journal, active only inside a transaction (tix #1541,
187// decision option 2; closes the `tix edit` window in #1302).
188//
189// Thread-local for the same reason the disclosure queue is: a CLI and an MCP
190// request each run on their own thread, and the test suite runs tests in
191// parallel, so a process-global stack would let one caller's rollback undo
192// another's writes.
193//
194// The journal is a **stack of undo records taken before each mutation**, not a
195// copy of the tree. That keeps a transaction cheap for the common case (one
196// file) while still covering the multi-file writes — `set_relationships`, a
197// reassign that moves a file and its asset dir, a status change — that are the
198// reason the layer exists.
199thread_local! {
200    static WRITE_JOURNAL: std::cell::RefCell<Option<Vec<Undo>>> =
201        const { std::cell::RefCell::new(None) };
202}
203
204/// One filesystem change a transaction can undo, recorded **before** it happens.
205enum Undo {
206    /// `path` held `before` (or did not exist in the `None` case) before a
207    /// write or a delete. Undone by restoring the bytes, or removing a file the
208    /// transaction created.
209    Write {
210        path: PathBuf,
211        before: Option<Vec<u8>>,
212    },
213    /// `from` was renamed to `to`. Undone by renaming it back. Used for the
214    /// file **and** its `<slug>-assets/` directory, so a reassign is one unit.
215    Move { from: PathBuf, to: PathBuf },
216}
217
218/// Record `path`'s pre-image, once, if a transaction is active.
219///
220/// The **first** pre-image wins: a file written twice in one transaction must be
221/// restored to the state it had before the transaction, not before the second
222/// write — otherwise rolling back after two writes would leave the first write's
223/// content behind.
224///
225/// `pub(crate)` because the mention store writes outside the issues directory and must
226/// participate in the same journal: a command whose `Store::transaction` rolls back has to
227/// roll its mention files back with its issue files.
228pub(crate) fn journal_record_write(path: &Path) {
229    WRITE_JOURNAL.with(|journal| {
230        let mut borrow = journal.borrow_mut();
231        let Some(entries) = borrow.as_mut() else {
232            return;
233        };
234        if entries
235            .iter()
236            .any(|undo| matches!(undo, Undo::Write { path: seen, .. } if seen == path))
237        {
238            return;
239        }
240        entries.push(Undo::Write {
241            path: path.to_path_buf(),
242            before: std::fs::read(path).ok(),
243        });
244    });
245}
246
247/// Record a rename, if a transaction is active.
248fn journal_record_move(from: &Path, to: &Path) {
249    WRITE_JOURNAL.with(|journal| {
250        let mut borrow = journal.borrow_mut();
251        let Some(entries) = borrow.as_mut() else {
252            return;
253        };
254        entries.push(Undo::Move {
255            from: from.to_path_buf(),
256            to: to.to_path_buf(),
257        });
258    });
259}
260
261/// Undo every recorded change, newest first.
262///
263/// Best-effort and infallible by design: a rollback that itself fails must not
264/// mask the error that triggered it, so each step ignores its own I/O result and
265/// the caller still returns the original error.
266fn rollback(entries: Vec<Undo>) {
267    for entry in entries.into_iter().rev() {
268        match entry {
269            Undo::Write { path, before } => match before {
270                Some(bytes) => {
271                    let _ = std::fs::write(&path, bytes);
272                }
273                None => {
274                    let _ = std::fs::remove_file(&path);
275                }
276            },
277            Undo::Move { from, to } => {
278                let _ = std::fs::rename(&to, &from);
279            }
280        }
281    }
282}
283
284/// The identity recorded as the `actor` of a History event.
285///
286/// Falls back to `unknown` rather than failing a mutation, so a repository with
287/// no configured git identity can still record an audit trail.
288///
289/// Public because the blocked-move guard (WP04) records its own History event
290/// outside the storage layer — the acknowledgement is a *policy* decision, not a
291/// storage step. Duplicating the git-identity resolution in the command layer
292/// would give the audit trail two definitions of "who acted", which is the kind
293/// of drift an audit trail exists to prevent.
294pub fn actor_identity() -> String {
295    crate::assignee::git_identity()
296        .map(|id| id.to_string())
297        .unwrap_or_else(|| "unknown".to_string())
298}
299
300/// A request to move an issue to a column, through the seam.
301///
302/// WP03 T013. A struct rather than a positional list because the three arguments
303/// are all `Option`-shaped and adjacent: `(Some(commit), None, None)` is a mistake
304/// waiting to happen at a call site, and the field names are what stop it.
305///
306/// ## Deliberately absent
307///
308/// **No `actor`.** The seam records the identity itself through
309/// [`actor_identity`], so a caller cannot attribute a decision to somebody else
310/// and the audit trail keeps one definition of "who acted". Passing an actor in
311/// would make the attribution a choice of the caller, which is exactly the thing
312/// an audit trail must not be.
313///
314/// **No `updated`.** The seam bumps it, on exactly the transitions that change
315/// something. A caller supplying it would be able to write a file whose date says
316/// a no-op was an edit.
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct StatusChange<'a> {
319    /// The column to move to. A column the board does not configure is refused
320    /// with `unknown_column`; a same-column request is accepted and inert.
321    pub column: &'a str,
322    /// The commit to record when this move reaches the board's completion column.
323    ///
324    /// Ignored on a move that does not reach completion, and a previously recorded
325    /// commit is cleared on a move that *leaves* it. `None` therefore means "record
326    /// nothing", which is distinct from "leave the old value alone" — leaving an
327    /// issue in its completion column with an unrelated commit is what the clear
328    /// exists to prevent.
329    pub commit: Option<&'a str>,
330    /// Why a policy rule was being overridden, when one was.
331    ///
332    /// Without a reason the service refuses a transition out of a terminal
333    /// category outright, so this is the only way out of `done`. The CLI flag that
334    /// supplies it lands in WP04; the seam takes the parameter now so a
335    /// half-built seam — one that can refuse but never accept-with-reason — is
336    /// never what ships.
337    pub override_reason: Option<&'a str>,
338}
339
340impl<'a> StatusChange<'a> {
341    /// A plain move: no commit, no override.
342    ///
343    /// The common case, named so the call site reads as "move it" rather than as
344    /// three `None`s.
345    pub fn to(column: &'a str) -> Self {
346        Self {
347            column,
348            commit: None,
349            override_reason: None,
350        }
351    }
352
353    /// This request with a commit, for a move that reaches completion.
354    pub fn with_commit(mut self, commit: Option<&'a str>) -> Self {
355        self.commit = commit;
356        self
357    }
358
359    /// This request with an override reason, for a move out of a terminal
360    /// category.
361    pub fn with_override(mut self, reason: Option<&'a str>) -> Self {
362        self.override_reason = reason;
363        self
364    }
365}
366
367/// What the seam did (`WP03` T013).
368///
369/// `written` and `decision` are deliberately redundant. Either alone would do:
370/// `written == false` implies `decision.is_none()`, and a decision always implies
371/// a write. They are both here because the two questions a caller asks are
372/// different — "did anything happen?" and "what gets appended?" — and reading the
373/// answer to one off the other is the kind of inference that breaks silently when a
374/// fourth outcome is added later.
375#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct StatusOutcome {
377    /// The issue as it now stands in the store: the accepted column, the bumped
378    /// `updated`, and the appended decision. On an inert no-op this is the issue
379    /// exactly as it was.
380    pub issue: Issue,
381    /// The decision appended to the issue's History, or `None` when nothing was
382    /// written.
383    pub decision: Option<TransitionDecision>,
384    /// Whether the file was written at all.
385    ///
386    /// `false` for a same-column re-apply and for any path that carries no change.
387    /// A refusal is not represented here — it is an `Err`, so "refused" and
388    /// "accepted but did nothing" are different types rather than one `Outcome`
389    /// with a flag.
390    pub written: bool,
391}
392
393/// The accepted half of [`crate::workflow::TransitionOutcome`], unpacked.
394///
395/// WP03. A private struct so the seam's body reads as ordinary code, and so the
396/// only way to reach the write is to have destructured the service's `Accepted`
397/// variant. The alternative — matching on the enum at each use — is how a future
398/// edit ends up handling a new outcome variant once in this function and not at
399/// all in the next one.
400struct TransitionAccepted {
401    changed: bool,
402    from: String,
403    to: String,
404    from_category: crate::workflow::WorkflowCategory,
405    to_category: crate::workflow::WorkflowCategory,
406    override_reason: Option<String>,
407}
408
409impl TransitionAccepted {
410    /// The accepted transition, or the diagnostic that refused it.
411    fn try_from(
412        outcome: crate::workflow::TransitionOutcome,
413    ) -> std::result::Result<Self, crate::workflow::WorkflowDiagnostic> {
414        match outcome {
415            crate::workflow::TransitionOutcome::Accepted {
416                changed,
417                from,
418                to,
419                from_category,
420                to_category,
421                override_reason,
422            } => Ok(Self {
423                changed,
424                from,
425                to,
426                from_category,
427                to_category,
428                override_reason,
429            }),
430            crate::workflow::TransitionOutcome::Rejected(diagnostic) => Err(diagnostic),
431        }
432    }
433}
434
435/// A [`Relationship`] standing for a canonical edge that has no stored row.
436///
437/// Deliberately local to the storage layer rather than a method on
438/// `CanonicalEdge`: the service's surface is owned by WP01 and is not widened
439/// from here. The id and display are empty because no row exists to carry them
440/// — this value only reports *which* edge a no-op concerned.
441fn canonical_placeholder(canonical: &CanonicalEdge) -> Relationship {
442    Relationship {
443        id: String::new(),
444        from: canonical.from,
445        to: canonical.to,
446        kind: canonical.kind,
447        display: String::new(),
448    }
449}
450
451/// Turn a blocking validation result into the crate error surface.
452///
453/// The first diagnostic's message is preserved verbatim so the user sees the
454/// field-aware text the service produced, prefixed with its diagnostic name
455/// (C-004). A blocking result means **no** file was written (SC-005), so this
456/// is raised before any mutation.
457fn relationship_error(errors: &[Diagnostic]) -> TixError {
458    if errors.is_empty() {
459        return TixError::InvalidInput("relationship rejected".into());
460    }
461    TixError::Refused(render_diagnostics(
462        "E-tix-relationship",
463        errors.iter().map(|d| (d.kind.as_str(), d.message.as_str())),
464    ))
465}
466
467/// A durable, exclusive claim on one issue number.
468///
469/// ## Why this exists (#1352, part 2)
470///
471/// [`Store::resolve_next_number`] reads the directory, takes `max(number)`, and adds
472/// one. That is a **hint**, not an allocation: nothing serialises read-then-write
473/// across processes, so two `tix create` runs that reach it together both get the
474/// same answer and both write an issue with that number.
475///
476/// Measured with 24 concurrent `tix create` subprocesses with distinct titles:
477///
478/// ```text
479/// files: 24   distinct numbers: 8
480///   5 × number: 7    4 × number: 4    4 × number: 6    3 × number: 3
481///   3 × number: 5    2 × number: 0    2 × number: 1    1 × number: 2
482/// ```
483///
484/// 16 of 24 issues shared a number with another file. After that every
485/// number-addressed operation — `tix view 0`, `tix move 0`, `tix edit 0`,
486/// `tix link 0 X`, `tix delete 0` — silently addresses an arbitrary one of several
487/// issues, which is arguably worse than the clobber it accompanies.
488///
489/// It needs **two** processes (an agent's `tix mcp` plus a human's `tix`), which is
490/// why it is rarer and why spacing the calls out appears to fix it. There is no
491/// intra-process race: `src/mcp/mod.rs` handles one message to completion before
492/// reading the next, the TUI is a single event loop, and `Store` is `&self`.
493///
494/// ## Why a claim ledger and not a lockfile
495///
496/// The obvious fix is a lock file held across resolve-and-write, and it was rejected
497/// because **a lock can be lost**: a process that crashes while holding it leaves the
498/// next create to break the lock on a timeout, and breaking a lock on a timeout also
499/// breaks it for a process that is merely slow. Every lock-staleness heuristic trades
500/// mutual exclusion for liveness, and this is the one property that must not be
501/// traded.
502///
503/// A claim ledger cannot lose mutual exclusion. The claim is a zero-byte file created
504/// with `O_EXCL`, and a process that dies holding one leaves an **orphan claim** —
505/// an allocated-but-unused number, never a lost lock. A burned number is a gap, and
506/// this system already produces gaps deliberately: reusing a number requires deleting
507/// every higher-numbered issue first, and deleting an issue leaves its number free.
508/// So the failure mode of the fix matches the existing, documented behaviour of the
509/// allocator rather than introducing a new one.
510///
511/// The cost is one hidden marker file per created issue. That is real, and it is the
512/// price of not needing a lease timeout. The markers live **beside** the issues
513/// directory rather than inside it, so nothing that reads `docs/issues/` — a glob, a
514/// script, a test counting segments — ever sees them; see [`claims_dir`].
515struct NumberClaim {
516    path: PathBuf,
517    /// Whether this claim must be released on drop. `false` once the issue file
518    /// exists, at which point the file — not the marker — is the authority for the
519    /// number.
520    armed: bool,
521}
522
523impl NumberClaim {
524    /// The directory holding one marker per claimed number.
525    const DIR: &'static str = ".tix-numbers";
526
527    /// Claim `start`, or the next free number at or above it.
528    ///
529    /// `start` comes from [`Store::resolve_next_number`] and is already past every
530    /// number any issue file claims, so the first candidate is normally free and the
531    /// loop runs once. The loop exists for the concurrent case, and the climb is
532    /// bounded so a corrupt ledger produces an error rather than an unbounded scan.
533    fn acquire(issues_dir: &Path, start: u32) -> Result<Self> {
534        const MAX_ATTEMPTS: u32 = 10_000;
535        let dir = claims_dir(issues_dir);
536        std::fs::create_dir_all(&dir)?;
537        let mut number = start;
538        for _ in 0..MAX_ATTEMPTS {
539            let path = dir.join(format!("{number}.claim"));
540            match std::fs::OpenOptions::new()
541                .write(true)
542                .create_new(true)
543                .open(&path)
544            {
545                Ok(_) => {
546                    return Ok(Self { path, armed: true });
547                }
548                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
549                    number += 1;
550                }
551                Err(e) => return Err(TixError::Io(e)),
552            }
553        }
554        Err(TixError::Io(std::io::Error::new(
555            std::io::ErrorKind::AlreadyExists,
556            format!(
557                "no free issue number at or above {start} after {MAX_ATTEMPTS} attempts; \
558                 {} is probably corrupt",
559                dir.display()
560            ),
561        )))
562    }
563
564    /// The number this claim holds.
565    fn number(&self) -> Option<u32> {
566        self.path.file_stem()?.to_str()?.parse::<u32>().ok()
567    }
568
569    /// Free a number that is no longer in use.
570    ///
571    /// Called by [`Store::delete`], so the ledger does not permanently reserve the
572    /// number of a deleted issue. Best-effort: if the ledger directory is missing or
573    /// the removal fails, the create path simply climbs past the stale claim — which
574    /// costs a number but never hands the same number to two issues.
575    fn release_number(issues_dir: &Path, number: u32) {
576        let _ = std::fs::remove_file(claims_dir(issues_dir).join(format!("{number}.claim")));
577    }
578
579    /// Give the number back, so a create that failed after claiming it does not burn
580    /// one. Idempotent, and a no-op for an already-committed claim.
581    fn release(&mut self) {
582        if self.armed {
583            let _ = std::fs::remove_file(&self.path);
584            self.armed = false;
585        }
586    }
587
588    /// Keep the claim permanently, because the issue file for it now exists.
589    ///
590    /// Without this, `Drop` would release the marker and the whole mechanism would
591    /// be a no-op — which is exactly what happened the first time this was written.
592    /// The `Drop` backstop below is for frames that *never reach* a commit, so it
593    /// must be disarmed deliberately on the success path rather than left to run
594    /// unconditionally.
595    fn commit(&mut self) {
596        self.armed = false;
597    }
598}
599
600impl Drop for NumberClaim {
601    /// A claim still armed when the `Store::create` frame unwinds — a `write`
602    /// error, or a panic — is released, so a failed create does not leak a number.
603    /// The `is_err` branch in `create` releases on the ordinary path; this is the
604    /// backstop for the paths that never reach it.
605    fn drop(&mut self) {
606        self.release();
607    }
608}
609
610impl std::fmt::Debug for NumberClaim {
611    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
612        f.debug_struct("NumberClaim")
613            .field("path", &self.path)
614            .field("armed", &self.armed)
615            .finish()
616    }
617}
618
619impl PartialEq for NumberClaim {
620    fn eq(&self, other: &Self) -> bool {
621        self.path == other.path
622    }
623}
624impl Eq for NumberClaim {}
625
626/// The outcome of a hierarchy write, mirroring [`RelationshipOutcome`] so a
627/// surface handles both with the same shape.
628#[derive(Debug, Clone)]
629pub struct ParentOutcome {
630    /// The child as written, or `None` when the mutation was a no-op.
631    ///
632    /// `None` for a no-op, matching [`RelationshipOutcome`]: a retried command must
633    /// not pad the audit trail, and a `changed: true` that wrote nothing is a lie.
634    /// A caller that needs the current state reads [`Self::parent`], which is
635    /// reported either way — the same split `RelationshipOutcome` uses for
636    /// `relationship`.
637    pub written: Option<Issue>,
638    /// The child's parent after the call.
639    pub parent: Option<u32>,
640    /// The event appended to `## History`, or `None` for a no-op.
641    ///
642    /// `None` for a no-op is the point: a retried command must not pad the
643    /// audit trail, and a `changed: true` that wrote nothing is a lie.
644    pub event: Option<crate::hierarchy::ParentEvent>,
645    /// Whether anything was written.
646    pub changed: bool,
647}
648
649/// The outcome of one project association mutation (mission
650/// `first-class-projects-01M3BYBW`, WP03).
651///
652/// Shaped like [`ParentOutcome`]: `written` is `None` for a no-op and the current
653/// list is reported either way, so a caller that needs the state does not have to
654/// re-read to learn it, and a `changed: false` is never paired with a write.
655#[derive(Debug, Clone, PartialEq, Eq)]
656pub struct ProjectAssociationOutcome {
657    /// The issue whose file was written, or `None` when the mutation was a no-op.
658    pub written: Option<Issue>,
659    /// The issue's association list after the call, exactly as the service
660    /// returned it.
661    pub project_ids: Vec<String>,
662    /// The History event appended, or `None` for a no-op.
663    pub event: Option<crate::projects::ProjectEvent>,
664    /// Whether anything was written.
665    pub changed: bool,
666}
667
668/// One entry of a bulk association run (WP03).
669///
670/// One entry per input selector, in input order, so a refusal is observable
671/// beside the rest rather than aborting them (contract `project-service.md` §3).
672/// It derives only `Debug` because [`TixError`] is neither `Clone` nor `PartialEq`.
673#[derive(Debug)]
674pub struct BulkAssociationOutcome {
675    /// The selector exactly as the caller wrote it.
676    pub selector: String,
677    /// The change, or the refusal, for that selector.
678    pub outcome: Result<ProjectAssociationOutcome>,
679}
680
681/// The outcome of one epic reference mutation (mission
682/// `epics-work-items-01M3BYW4`, WP03).
683///
684/// Shaped like [`ProjectAssociationOutcome`]: `written` is `None` for a no-op and
685/// the current list is reported either way, so a caller that needs the state does
686/// not have to re-read to learn it, and a `changed: false` is never paired with a
687/// write.
688#[derive(Debug, Clone, PartialEq, Eq)]
689pub struct EpicReferenceOutcome {
690    /// The issue whose file was written, or `None` when the mutation was a no-op.
691    pub written: Option<Issue>,
692    /// The issue's reference list after the call, exactly as the service returned
693    /// it.
694    pub epic_ids: Vec<String>,
695    /// The History event appended, or `None` for a no-op.
696    pub event: Option<crate::epics::EpicEvent>,
697    /// Whether anything was written.
698    pub changed: bool,
699}
700
701/// One entry of a bulk reference run (WP03).
702///
703/// One entry per input selector, in input order, so a refusal is observable
704/// beside the rest rather than aborting them (`contracts/epic-service.md` §5).
705/// It derives only `Debug` because [`TixError`] is neither `Clone` nor `PartialEq`.
706#[derive(Debug)]
707pub struct BulkReferenceOutcome {
708    /// The selector exactly as the caller wrote it.
709    pub selector: String,
710    /// The change, or the refusal, for that selector.
711    pub outcome: Result<EpicReferenceOutcome>,
712}
713
714impl Store {
715    /// File counts at or above this fan the scan's read+parse across threads.
716    /// Below it a serial loop is faster than the thread setup, so a small
717    /// repository keeps the boring path.
718    const PARALLEL_SCAN_THRESHOLD: usize = 256;
719    /// Upper bound on scan threads. The work is read- and YAML-parse-bound;
720    /// unbounded fan-out adds scheduling cost without throughput.
721    const PARALLEL_SCAN_THREADS: usize = 8;
722
723    /// The mention store, or `None` when `[mentions] enabled = false`.
724    ///
725    /// One constructor for every call site — the write seam, the TUI's indicator and the CLI's
726    /// `tix mentions` — so "disabled" cannot mean different things in different places: no
727    /// store, no scan, no write.
728    ///
729    /// The directory resolves against the **project root**, the same rule `issues_dir` follows,
730    /// so a `tix` run from a subdirectory reads and writes the same files.
731    pub fn mention_store(&self) -> Option<crate::mentions::store::MentionStore> {
732        if !self.config.mentions.enabled {
733            return None;
734        }
735        let dir = self.config.mentions.resolve_dir(&self.issues_dir);
736        Some(crate::mentions::store::MentionStore::new(dir))
737    }
738
739    /// Reconcile `issue`'s mentions into the store, best-effort.
740    ///
741    /// Called from every method that persists issue content ([`Store::create`],
742    /// [`Store::update`] — which `Store::set_status` reaches — and the clearing side of
743    /// [`Store::delete`]), so no surface can write an issue without its mentions being
744    /// reconciled. It never fails the write: a problem is raised as a disclosure and the issue
745    /// write stands.
746    fn reconcile_mentions(&self, issue: &Issue, file: &str) {
747        let Some(store) = self.mention_store() else {
748            return;
749        };
750        // A legacy issue with no `id:` falls back to its path, which is stable for as long as
751        // the issue is not moved — the same best-effort the model makes everywhere else.
752        let id = issue.id.as_deref().unwrap_or(file);
753        store.reconcile(crate::mentions::IssueContent {
754            id,
755            number: issue.number,
756            file,
757            title: &issue.title,
758            body: &issue.body,
759        });
760    }
761
762    /// Drop every stored mention for the issue with `id`, best-effort.
763    fn clear_mentions(&self, id: &str) {
764        let Some(store) = self.mention_store() else {
765            return;
766        };
767        if let Err(e) = store.clear_issue(id) {
768            crate::storage::mention_disclosure(format!("mentions: {e} (left untouched)"));
769        }
770    }
771
772    // -- T008 — hierarchy writes ------------------------------------------
773    //
774    // Validate-then-write, exactly as `link` does it. The service owns the rule
775    // and produces the History event; this layer performs the write. A blocking
776    // result writes nothing and appends nothing (contract §4, SC-004).
777
778    // -- WP03 — project associations ---------------------------------------
779    //
780    // Validate-then-write, exactly as `link` and the parent operations do it.
781    // `crate::projects` is the single owner of membership, uniqueness, and
782    // reference rules; this layer performs the write the service authorised and
783    // holds **no project rule of its own**. It applies the list the service
784    // returned, verbatim, and appends the one event the service produced.
785
786    // -- WP03 — epic references --------------------------------------------
787    //
788    // Validate-then-write, exactly as `link`, the parent operations, and the
789    // project associations do it. `crate::epics` is the single owner of
790    // reference rules; this layer performs the write the service authorised and
791    // holds **no epic rule of its own**. It applies the list the service
792    // returned, verbatim, and appends the one event the service produced.
793}
794
795/// Whether a **relative** path is one [`Store::scan`] would index as an issue
796/// file, at any depth (issue #10 follow-up; configurable layout).
797///
798/// A path is an issue file when every component is a normal name, no component
799/// is a `.tix-tmp-*` temp dir or a `<slug>-assets/` dir, and the final component
800/// ends in `.md`. Depth is unbounded so the incremental corpus matches a full
801/// rebuild under any `[layout]` template.
802pub(crate) fn is_issue_rel_path(rel: &Path) -> bool {
803    let mut file_name: Option<&std::ffi::OsStr> = None;
804    for component in rel.components() {
805        match component {
806            std::path::Component::Normal(segment) => {
807                let Some(s) = segment.to_str() else {
808                    return false;
809                };
810                if s.starts_with(".tix-tmp-") || s.ends_with("-assets") {
811                    return false;
812                }
813                file_name = Some(segment);
814            }
815            std::path::Component::CurDir => {}
816            // RootDir, ParentDir, Prefix: not a store-relative issue path.
817            _ => return false,
818        }
819    }
820    file_name.is_some_and(|name| name.to_string_lossy().ends_with(".md"))
821}
822
823/// Validate an issue filename. A filename is either a legacy flat basename
824/// (`2026-05-10-fix-login.md`) or a grouped path with exactly one assignee
825/// segment prefix (`alice/2026-05-10-fix-login.md`, C-008/C-010).
826///
827/// Refused in all cases: NUL bytes, backslashes, the `..` component, empty
828/// components (leading/trailing/double slashes), absolute / leading-slash
829/// paths, and nesting deeper than one segment. Every refusal is a hard
830/// [`TixError::InvalidInput`] raised before any filesystem touch.
831fn validate_filename(filename: &str) -> Result<()> {
832    if filename.is_empty() {
833        return Err(TixError::InvalidInput(format!(
834            "filename must not be empty: {filename:?}"
835        )));
836    }
837    if filename.starts_with('/') {
838        return Err(TixError::InvalidInput(format!(
839            "filename must be relative (no leading `/`): {filename}"
840        )));
841    }
842    if filename.contains('\\') {
843        return Err(TixError::InvalidInput(format!(
844            "filename must not contain backslashes: {filename}"
845        )));
846    }
847    if filename.contains("..") {
848        return Err(TixError::InvalidInput(format!(
849            "filename must not contain `..`: {filename}"
850        )));
851    }
852    if filename.contains('\0') {
853        return Err(TixError::InvalidInput(format!(
854            "filename must not contain NUL: {filename}"
855        )));
856    }
857    if filename.split('/').any(|component| component.is_empty()) {
858        return Err(TixError::InvalidInput(format!(
859            "filename path components must not be empty: {filename}"
860        )));
861    }
862    Ok(())
863}
864
865/// Split a validated relative filename into `(segment, basename)`. Legacy
866/// flat filenames have an empty segment (`("", "2026-05-10-x.md")`), grouped
867/// ones carry exactly one segment (`("alice", "2026-05-10-x.md")`). [`None`]
868/// for anything that survived a bad path shape.
869/// The `number:` field of an issue file's frontmatter, without a full parse.
870///
871/// `Store::delete` needs the number of the issue it is removing so it can free that
872/// number's claim, and it needs it *before* the file is gone. A full `Issue` parse
873/// would be the obvious way, but this sits on the delete path and only needs one
874/// scalar, so a scoped scan is enough — and it cannot fail on an issue that would
875/// fail to load, which keeps `delete`'s existing "removes the file" contract
876/// (#1352) independent of frontmatter validity.
877///
878/// The scan stops at the closing `---` so a `number:` in the **body** cannot be
879/// mistaken for the field.
880fn frontmatter_number(text: &str) -> Option<u32> {
881    let mut lines = text.lines();
882    // The opening delimiter. A file that does not start with one has no frontmatter.
883    if lines.next()?.trim() != "---" {
884        return None;
885    }
886    for line in lines {
887        let line = line.trim_end();
888        // The closing delimiter ends the frontmatter, so a `number:` after it is
889        // body text and must not be mistaken for the field.
890        if line == "---" {
891            return None;
892        }
893        if let Some(rest) = line.strip_prefix("number:") {
894            return rest.trim().parse().ok();
895        }
896    }
897    None
898}
899
900/// The directory holding one marker per claimed number.
901///
902/// **A sibling of the issues directory, not a child of it.** `docs/issues/` is the
903/// tool's source of truth: people hand-edit it, script against it, and glob it, and
904/// its contents are committed. Adding an internal directory inside it changes what
905/// every one of those consumers sees — a test helper that counts segments, a `for`
906/// loop over `docs/issues/*`, a CI check — for machinery no reader should have to
907/// know about. Beside it, `docs/issues/` contains exactly what it did before.
908///
909/// Derived from the configured `issues_dir` rather than hardcoded, so a repo that
910/// points `issues_dir` somewhere else still gets a ledger beside that.
911fn claims_dir(issues_dir: &Path) -> PathBuf {
912    issues_dir
913        .parent()
914        .unwrap_or(issues_dir)
915        .join(NumberClaim::DIR)
916}
917
918fn split_relative(filename: &str) -> Option<(String, String)> {
919    let mut parts = filename.rsplitn(2, '/');
920    let basename = parts.next()?;
921    let segment = parts.next().unwrap_or("");
922    Some((segment.to_string(), basename.to_string()))
923}
924
925/// Flip the archived marker on a store-relative issue filename.
926///
927/// `alice/2026-01-01-x.md` → `alice/.2026-01-01-x.md` when `archive` is true,
928/// and the inverse when false. Only the basename's leading dot changes; the
929/// segment stays put, so the file never moves between directories. `None` when
930/// the name is already in the requested state.
931fn flip_archived(filename: &str, archive: bool) -> Option<String> {
932    let (segment, basename) = split_relative(filename)?;
933    let flipped = if archive {
934        if basename.starts_with('.') {
935            return None;
936        }
937        format!(".{basename}")
938    } else {
939        basename.strip_prefix('.')?.to_string()
940    };
941    Some(if segment.is_empty() {
942        flipped
943    } else {
944        format!("{segment}/{flipped}")
945    })
946}
947
948/// The first free `<stem>.md` / `<stem>-1.md` / `<stem>-2.md` … name in
949/// `dir` (deterministic existence probe). Never returns an existing name.
950fn resolve_target_name(dir: &Path, stem: &str) -> String {
951    let mut candidate = format!("{stem}.md");
952    let mut i = 1u32;
953    while dir.join(&candidate).exists() {
954        candidate = format!("{stem}-{i}.md");
955        i += 1;
956    }
957    candidate
958}
959
960/// The slug used for the asset dir: the issue filename stem minus any leading
961/// date and id components.
962///
963/// New schema is `YYYY-MM-DD-<id>-<slug>` (`2026-09-13-A1B2C3D4-fix-login-bug` →
964/// `fix-login-bug`); legacy is `YYYY-MM-DD-<slug>`; an earlier new-schema order
965/// was `<id>-YYYY-MM-DD-<slug>`. All three are handled so the asset directory is
966/// the same human slug regardless of which name a file carries. When neither
967/// component is present the stem is used as-is.
968fn slug_from_stem(stem: &str) -> String {
969    let stem = strip_leading_id(stem);
970    let stem = strip_date_prefix(stem);
971    strip_leading_id(stem).to_string()
972}
973
974/// Strip a leading 8-character issue id and its separator, if present.
975fn strip_leading_id(stem: &str) -> &str {
976    match stem.split_once('-') {
977        Some((head, rest)) if crate::id::is_issue_id(head) => rest,
978        _ => stem,
979    }
980}
981
982/// Strip a leading `YYYY-MM-DD-` date prefix, if present.
983fn strip_date_prefix(stem: &str) -> &str {
984    let bytes = stem.as_bytes();
985    if stem.len() > 10 && bytes[4] == b'-' && bytes[7] == b'-' && bytes[10] == b'-' {
986        &stem[11..]
987    } else {
988        stem
989    }
990}
991
992/// The first free `<name>` / `<name>-<n>.<ext>` name for an asset in `dir`.
993/// When `<name>` is taken the extension is kept and a deterministic
994/// numeric suffix is appended (FR-006).
995fn resolve_asset_name(dir: &Path, name: &str) -> String {
996    if !dir.join(name).exists() {
997        return name.to_string();
998    }
999    let (stem, ext) = match name.rsplit_once('.') {
1000        Some((s, e)) => (s, format!(".{e}")),
1001        None => (name, String::new()),
1002    };
1003    let mut i = 1u32;
1004    loop {
1005        let candidate = format!("{stem}-{i}{ext}");
1006        if !dir.join(&candidate).exists() {
1007            return candidate;
1008        }
1009        i += 1;
1010    }
1011}
1012
1013/// The first free `<name>` / `<name>-1` / `<name>-2` … directory name in
1014/// `dir` (mirrors `resolve_target_name` for the `<slug>-assets/` move).
1015fn resolve_asset_dir_name(dir: &Path, name: &str) -> String {
1016    let mut candidate = name.to_string();
1017    let mut i = 1u32;
1018    while dir.join(&candidate).exists() {
1019        candidate = format!("{name}-{i}");
1020        i += 1;
1021    }
1022    candidate
1023}
1024
1025/// Copy a file atomically into `dir` under `target` (temp + rename, NFR-004).
1026/// The temp file is cleaned up on failure and never left behind.
1027fn atomic_copy(dir: &Path, target: &Path, source: &Path) -> Result<()> {
1028    let pid = std::process::id();
1029    let nonce = nanos();
1030    let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
1031    let copy_result = (|| -> std::io::Result<()> {
1032        std::fs::copy(source, &tmp)?;
1033        let f = std::fs::File::open(&tmp)?;
1034        f.sync_all()?;
1035        Ok(())
1036    })();
1037    if let Err(e) = copy_result {
1038        let _ = std::fs::remove_file(&tmp);
1039        return Err(TixError::Io(e));
1040    }
1041    if let Err(e) = std::fs::rename(&tmp, target) {
1042        let _ = std::fs::remove_file(&tmp);
1043        return Err(TixError::Io(e));
1044    }
1045    Ok(())
1046}
1047
1048/// Whether an asset file name is an image by extension (the `![…]` link form).
1049fn is_image_name(name: &str) -> bool {
1050    matches!(
1051        name.rsplit('.')
1052            .next()
1053            .unwrap_or("")
1054            .to_ascii_lowercase()
1055            .as_str(),
1056        "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp"
1057    )
1058}
1059
1060/// `pub(crate)` because the mention store writes its own directory through the same primitive:
1061/// one atomic writer means one place that journals, fsyncs, and renames.
1062pub(crate) fn atomic_write(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
1063    write_file(dir, target, data, Link::Overwrite)
1064}
1065
1066/// Write `data` to `target`, refusing to replace an existing file.
1067///
1068/// [`atomic_write`] uses `rename`, which replaces the target unconditionally and is
1069/// therefore the wrong tool for *creating* an issue: a name collision would destroy
1070/// the file already there. This writes the payload to a temp file, `fsync`s it, and
1071/// then creates `target` with `hard_link`, which is atomic and fails with
1072/// `AlreadyExists` if the name is taken. The temp file is removed either way.
1073///
1074/// A hard link is the portable way to get `O_EXCL`-style semantics on the final
1075/// path (it works on unix and Windows and needs no new dependency). The temp file
1076/// lives in the same directory, so it is always on the same filesystem and the link
1077/// cannot fail for that reason. If the link is refused for any other reason — a
1078/// filesystem without hard links, say — it falls back to `rename`, which keeps
1079/// atomicity but is no longer no-clobber, and the caller's own probe is the
1080/// remaining protection.
1081///
1082/// The directory `fsync` that follows the link is what makes the *name* durable, so
1083/// it is not skippable on the fallback path either.
1084fn write_file_new(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
1085    write_file(dir, target, data, Link::Exclusive)
1086}
1087
1088#[derive(Clone, Copy)]
1089enum Link {
1090    Overwrite,
1091    Exclusive,
1092}
1093
1094fn write_file(dir: &Path, target: &Path, data: &[u8], link: Link) -> Result<()> {
1095    // Every write goes through here, so this is the one place a transaction needs
1096    // to remember the target's pre-image. A no-op outside a transaction.
1097    journal_record_write(target);
1098    let pid = std::process::id();
1099    let nonce = nanos();
1100    let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
1101    let write_result = (|| -> std::io::Result<()> {
1102        let mut f = std::fs::File::create(&tmp)?;
1103        use std::io::Write;
1104        f.write_all(data)?;
1105        f.sync_all()?;
1106        Ok(())
1107    })();
1108    if let Err(e) = write_result {
1109        let _ = std::fs::remove_file(&tmp);
1110        return Err(TixError::Io(e));
1111    }
1112    let link_result = match link {
1113        Link::Overwrite => std::fs::rename(&tmp, target),
1114        Link::Exclusive => match std::fs::hard_link(&tmp, target) {
1115            // The name was taken: the payload is already durable, so only the link
1116            // failed. `AlreadyExists` is the signal `write_new_issue_file` retries on,
1117            // so it is propagated rather than flattened into a generic I/O error.
1118            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
1119            // No hard links on this filesystem. Atomicity survives; the no-clobber
1120            // guarantee does not, and the caller's probe is what is left.
1121            Err(_) => std::fs::rename(&tmp, target),
1122            Ok(()) => {
1123                let _ = std::fs::remove_file(&tmp);
1124                Ok(())
1125            }
1126        },
1127    };
1128    if let Err(e) = link_result {
1129        let _ = std::fs::remove_file(&tmp);
1130        return Err(TixError::Io(e));
1131    }
1132    let _ = std::fs::File::open(dir).and_then(|f| f.sync_all());
1133    Ok(())
1134}
1135
1136fn nanos() -> u64 {
1137    SystemTime::now()
1138        .duration_since(UNIX_EPOCH)
1139        .map(|d| d.as_nanos() as u64)
1140        .unwrap_or(0)
1141}
1142
1143#[cfg(test)]
1144mod tests;