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;