Skip to main content

Store

Struct Store 

Source
pub struct Store {
    pub issues_dir: PathBuf,
    pub config: Config,
}

Fields§

§issues_dir: PathBuf§config: Config

Implementations§

Source§

impl Store

Source

pub fn reference_epic( &self, issue_selector: &str, epic_reference: &str, ) -> Result<EpicReferenceOutcome>

Reference an epic from an issue.

The service resolves the epic (by id, or by name as a convenience), enforces (issue, epic_id) uniqueness, and returns the resulting list plus the History event; this method writes them through the existing Store::update path. A refused reference writes nothing and appends nothing, and an already-present reference is an idempotent no-op that does the same.

Source

pub fn dereference_epic( &self, issue_selector: &str, epic_id: &str, ) -> Result<EpicReferenceOutcome>

Remove an issue’s reference to an epic id.

epic_id is compared verbatim against the stored list, as the service documents: a name is not resolved here, so removing a stale id that is no longer a valid epic shape still works — the repair path C-005 promises. Dereferencing an absent reference is an idempotent no-op.

Source

pub fn bulk_reference( &self, epic_reference: &str, selectors: &[String], ) -> Result<Vec<BulkReferenceOutcome>>

Reference an epic from each selector independently, writing every accepted change.

One rejection must not abort the rest (contracts/epic-service.md §5): the returned vector has one entry per selector, in input order. Each accepted change is written to its own file and appends exactly one History event; a refusal writes nothing. An unresolvable selector becomes that entry’s own error rather than failing the run.

§Two selectors for one issue

Selectors are resolved and applied one at a time against a working copy of the issue set, advanced after every accepted write — the same reasoning as Store::bulk_associate. Applying each selector to the one immutable read would let a second selector naming the same issue miss the first write, so the service’s already-present branch would not fire and a second epic_referenced entry would be appended for a reference that is already there. With the working copy the second commit is seen, so idempotence stays the service’s decision rather than a membership rule re-implemented here.

Source§

impl Store

Source

pub fn create( &self, issue: &mut Issue, body: &str, filename_stem: Option<&str>, ) -> Result<PathBuf>

Create an issue file atomically and assign its number by scanning the live issue set (issue #52): the highest existing number plus one, with a floor of 0 for an empty store.

The number is resolved at creation time rather than read from a persisted [state].next_number counter, so tix.toml is never rewritten by create. A deleted high-numbered issue’s number can be reused by a later create; the frontmatter number stays stable for every surviving issue (C-007).

The file is written into the assignee segment derived from issue.assignee (unassigned/ fallback, FR-029/FR-030), creating the segment directory as needed, and issue.filename is set to the relative path so create output and later edits agree. Atomic-write discipline (temp + rename, NFR-006) is preserved.

Source

pub fn update(&self, filename: &str, issue: &Issue) -> Result<String>

Write issue over the file at filename, then reconcile the file’s location with the configured [layout] (move_on_change).

Returns the store-relative path the issue lives at afterwards — the same one in the common case, a new one when a templated field (status, type, assignee, cycle, …) now renders a different directory. Every write path goes through here, so no surface (CLI, MCP, TUI, triage, sync) can leave an issue at a path that disagrees with the layout.

Source

pub fn delete(&self, filename: &str) -> Result<()>

Source

pub fn archive(&self, sel: &str) -> Result<Issue>

Archive an issue: rename its file to a dot-prefixed name in place.

The marker is the filename (issue #1492), so this is a rename within the same assignee segment (x.md ↔ .x.md) plus exactly one ## History event. number, status, created and every other frontmatter field are left untouched, no data is dropped, and the file is never deleted or moved between directories.

An already-archived issue is refused with the already_archived diagnostic rather than re-archived: the operation is not idempotent by design, because a second call would append a second audit entry for a mutation that did not happen.

Source

pub fn restore(&self, sel: &str) -> Result<Issue>

Restore an archived issue: the inverse of Store::archive.

An active issue is refused with the not_archived diagnostic.

Source

pub fn reassign( &self, filename: &str, new_assignee: Option<&str>, ) -> Result<PathBuf>

Physically move an issue file into the assignee segment of a new assignee (FR-031, contracts/file-format.md rule 9).

filename is a path relative to the issues dir: either a legacy flat basename (2026-05-10-fix-login.md, C-010) or a grouped path (alice/2026-05-10-fix-login.md). It is validated via the relaxed [validate_filename] before anything touches the filesystem, so traversal attempts are refused here as everywhere else.

The old assignee segment is read from the current relative path, the new one derived with [derive_segment]; equal segments are a no-op. The target filename keeps the original created-slug stem; when the target already holds <stem>.md, a deterministic -1, -2, … suffix is appended by testing existence — an existing file is never overwritten or deleted. number/created live in the frontmatter and are untouched, so issue-number stability is guaranteed by construction (C-007).

The move is a single std::fs::rename (atomic-ish, same filesystem). Returns the new relative path (matching how issue.filename is stored) on success, including the unchanged relative path for the equal-segment no-op.

Source

pub fn migrate_issue(&self, filename: &str, target: &str) -> Result<()>

Relocate an issue file to an exact store-relative target (used by tix layout migrate). Frontmatter and number are preserved because nothing is rewritten — it is a rename; an occupied target is refused; the <slug>-assets/ dir moves alongside.

Source

pub fn move_to_layout(&self, issue: &mut Issue) -> Result<bool>

Move an issue to the directory its templated fields now render to, when move_on_change is set. The basename is preserved. Returns whether it moved; a no-op when already in place or when move_on_change is off.

Source

pub fn add_asset( &self, filename: &str, source_path: &Path, ) -> Result<AssetAdded>

Copy an asset into the issue’s <slug>-assets/ directory (FR-006) and insert a relative markdown link at the end of the body, persisted via Store::update (NFR-004).

The asset dir is derived from the issue filename per contracts/comments-format.md: <slug>-assets/ where <slug> is the basename stem minus the leading YYYY-MM-DD- date prefix. The copy is atomic (temp + rename, NFR-004); on a name collision the file is stored as <stem>-<n>.<ext> (first free n) and the inserted link points at the actual written name. Image extensions (.png .jpg .jpeg .gif .svg .webp .bmp) get the ![…](…) form, everything else the […](…) form. The link path is relative to the issue file, so the segment prefix is never part of it.

Source§

impl Store

Source

pub fn set_parent(&self, child: &str, parent: &str) -> Result<ParentOutcome>

Point child at parent, or repoint it if it already has one.

Both selectors go through Store::resolve_issue, so #4, 4 and a title fragment all work and an unresolvable one produces the CLI’s own message. The canonical issue id is what lands in the file, because neither a title nor a branch-local number is a stable identity.

Source

pub fn change_parent(&self, child: &str, parent: &str) -> Result<ParentOutcome>

Repoint child at a new parent. A no-op when the parent is unchanged.

Source

pub fn clear_parent(&self, child: &str) -> Result<ParentOutcome>

Remove child’s parent, yielding the absent state.

Source

pub fn hierarchy_index(&self) -> Result<(HierarchyIndex, Vec<Issue>)>

The in-memory snapshot the hierarchy service reads.

pub because the --parent edit flag (WP03) must validate through the service before any write, exactly as this module does. It is a read, so exposing it costs no more than a duplicate list() would.

One IssueView per issue, shared with the relationship service rather than a second projection — two views of one issue type is how two services end up disagreeing about the same snapshot.

The Vec is returned rather than discarded because the write path needs the child from this same snapshot. Re-reading it through a second list() would give the write and the validation two different notions of “current”: two files carrying the same number: (reachable by hand-edit, since HierarchyIndex::build keys by number and last-write-wins while Store::get is first-match), or a concurrent writer, would make the service decide on one parent and the writer emit another. The invariant is then not merely unasserted, it is false.

Source

pub fn relationship_done_column(&self) -> String

Build a RelationshipGraph over every issue in the store.

The service is the only place relationship semantics live, so the write path validates through it rather than re-deriving direction handling, cycle detection, or blocked state locally (C-003). The board’s configured completion column.

Read from the first configured board. Completion is always resolved from configuration, never by matching the literal string done; when no board configures one, the service’s done fallback applies.

Resolved from the active board — the one named default, falling back to the first declared, which is exactly what Config::default_config returns and what every other single-surface consumer already uses.

It used to take the first declared board instead (configurations.values().next()). That is a different board whenever a configuration is declared before default with a different done_column, and it put two different boards inside a single decision: blocked_move_guard asked “is the target the completion column?” via completion_column(config) — the active board — and then “is this issue blocked?” via this function’s graph — the first declared board. The guard was reasoning about two different workflows in one call.

data-model.md §4 says completion “is always resolved through the board’s configured done_column” and that the predicate is “the only definition used by FR-003, FR-004, the move guard, and every surface”. That requires one board, not “whichever is declared first”.

Source§

impl Store

Source

pub fn associate( &self, issue_selector: &str, project_reference: &str, ) -> Result<ProjectAssociationOutcome>

Associate an issue with a project.

The service resolves the project (by id, or by name as a convenience), enforces uniqueness, and returns the resulting list plus the History event; this method writes them through the existing Store::update path. A refused association writes nothing and appends nothing, and an already-present association is an idempotent no-op that does the same.

Source

pub fn dissociate( &self, issue_selector: &str, project_id: &str, ) -> Result<ProjectAssociationOutcome>

Remove an issue’s association with a project id.

project_id is compared verbatim against the stored list, as the service documents: a name is not resolved here, so removing a stale id that is no longer a valid project shape still works — the repair path C-005 promises. Dissociating an absent association is an idempotent no-op.

Source

pub fn bulk_associate( &self, project_reference: &str, selectors: &[String], ) -> Result<Vec<BulkAssociationOutcome>>

Associate each selector independently, writing every accepted change.

One rejection must not abort the rest (contract §3): the returned vector has one entry per selector, in input order. Each accepted change is written to its own file and appends exactly one History event; a refusal writes nothing. An unresolvable selector becomes that entry’s own error rather than failing the run.

§Two selectors for one issue

Selectors are resolved and applied one at a time against a working copy of the issue set, advanced after every accepted write. Applying each selector to the one immutable read instead would let a second selector naming the same issue (say 1 and 2026-01-01-a.md) miss the first write: the service’s already-present branch would not fire and a second project_associated entry would be appended for an association that is already there. With the working copy the second commit is seen, so idempotence stays the service’s decision rather than a membership rule re-implemented here, and the repeated selector is reported per selector as the no-op it is (written: None, event: None, changed: false).

Source§

impl Store

Source

pub fn open(config: &Config) -> Result<Store>

Source

pub fn transaction<T>(&self, f: impl FnOnce() -> Result<T>) -> Result<T>

Run f, undoing every filesystem mutation it made if it returns Err.

A command like tix edit is several writes in sequence — a reassign that moves the file, the merged frontmatter, a --status transition, a triage stamp — and the writes are not individually atomic with each other. Without this, an I/O failure between two of them commits the earlier one and leaves the store half-mutated (tix #1302). Wrapping the command in Store::transaction makes the sequence all-or-nothing: on any Err, every file the command touched is restored to the bytes it had before.

The journal is recorded by the write primitives themselves ([write_file], the renames in move_issue_file, delete), so any store method participates without knowing about transactions — the layer is command-level, not a per-method flag.

Nesting: only the outermost call opens a journal, so an inner transaction joins the outer one and a failure still rolls the whole outer unit back. This is what makes it safe to wrap a public command that itself calls other transactional commands.

Not covered, stated rather than implied: writes that bypass the primitives (a future fs::write written by hand) and mutations made after f returns. The rollback is best-effort per file and the original error is always the one returned — a rollback that itself fails must not mask the cause.

Source

pub fn list(&self) -> Result<Vec<Issue>>

The active issue set: every issue file that is not archived.

Archived issues are marked by a dot-prefixed markdown filename (docs/issues/<assignee>/.<file>.md, issue #1492). They are skipped from the directory entry, before the file is opened, so the default path never parses an archived issue — the performance requirement that keeps a repository with thousands of archived issues fast.

Use Store::list_all when an archived issue must still be reachable.

Source

pub fn list_all(&self) -> Result<Vec<Issue>>

The exhaustive issue set: active issues plus archived ones.

Archived issues carry archived == true, derived from the leading dot of their filename. This is the enumeration for selector resolution and number allocation, both of which must see an archived issue; it is deliberately not the default view.

Source

pub fn assignee_segments(&self) -> Result<Vec<String>>

The assignee storage folders under the issues directory, sorted.

These are the docs/issues/<username>/ segments Self::scan walks — the ground truth for who has issues. The reserved segments are excluded: unassigned is the fallback for “no assignee” and is not a person, and a <slug>-assets/ or .tix-tmp-* directory is not a person either. Used by the Triage mode’s assignee picker (design D6); never creates a directory.

Source

pub fn get(&self, selector: IssueSelector) -> Result<Issue>

Source

pub fn read_issue_at(&self, rel: &Path) -> Result<Issue>

Read one issue by its store-relative filename (issue #10 follow-up).

The single-file counterpart of Store::scan, for the incremental corpus path: it sets filename and archived exactly as scan does (the archived marker is the leading dot of the file name) and applies the same parse-error context, so an incrementally updated corpus holds byte-identical issues to a full rebuild. It deliberately does not linear-scan IssueSelector — the caller already has the relative path from the watcher.

A path that is not a file is NotFound; the caller decides whether that means “removed”.

Source

pub fn resolve_issue(&self, raw: &str) -> Result<Issue>

Resolve a selector string exactly like the selector-taking commands (parse_selector) and fetch the issue (D2: “Resolver SHALL match tix edit/tix move”).

pub because the --parent flags (WP03) resolve a parent selector to its number through this same path, so #4, 4 and a title fragment all work on a parent exactly as they do on any other selector — and a second resolver in the command layer is how two surfaces end up disagreeing about which issue a selector names.

Source

pub fn path_is_laid_out(&self, filename: &str, issue: &Issue) -> bool

Whether filename already sits at the directory its templated fields render to. Used to decide whether a mutation may relocate the file: only a file already on the layout is moved, so a legacy or mismatched path is left to tix layout migrate.

Source

pub fn config_path(&self) -> Result<PathBuf>

Source§

impl Store

Create the typed relationship a --kind--> b.

Selectors resolve with the same grammar as the selector-taking commands. The edge is stored once, in the frontmatter of whichever issue owns the canonical edge — the from side for a directional kind, and the lower-numbered side for related, which is order-normalized so both spellings of an association are one edge. No back-reference is written on the other issue: that symmetry was the retired model.

Validation runs through the service first, so a self-link, a missing target, a conflicting kind, or a cycle is refused before any file is touched (SC-005). Re-asserting an edge that already exists is an idempotent no-op: no file is written and no History event is appended.

Remove the typed relationship a --kind--> b.

Removing an edge that is not there is an idempotent no-op: no file is written and no History event is appended.

Source

pub fn edit_relation( &self, id: &str, new_to: Option<u32>, new_kind: Option<RelationKind>, ) -> Result<RelationshipOutcome>

Retarget and/or re-kind the relationship with this stable id.

The id is generated once and survives the edit, so a relationship a user has already referenced elsewhere does not change identity when its target moves (contract §1). new_to and new_kind are both optional and naming neither is the identity edit: nothing is written and no History event is appended.

Validation goes through RelationshipGraph::validate_edit, so an edit that would self-link, point at a missing issue, duplicate an existing edge, or close a cycle is refused before any file is touched (SC-005). An edit may change the kind but never the source: the source is the edge’s owner, and moving it would mean deleting a relationship and creating another, which is what remove plus link is for.

Source

pub fn remove_relation(&self, id: &str) -> Result<RelationshipOutcome>

Remove the relationship with this stable id.

An id that is not stored is an idempotent no-op: no file is written and no History event is appended, exactly as Store::unlink treats an absent edge. tix link remove is therefore safe to retry.

Source

pub fn graph_source(&self) -> Result<(Vec<Issue>, String)>

The issue set plus the board’s configured completion column, for building a RelationshipGraph over the whole store.

The issue set is returned alongside the column rather than as a finished graph because the graph borrows the issues: a caller that wants three derived reads (relationships_for, blocked_state, dependency_chain) over one snapshot would otherwise pay three separate O(V + E) builds and risk the three disagreeing.

This is the single place a read surface obtains its graph, so no surface can reach past the service to re-derive direction handling, cycle detection, or blocked state (C-003).

Reads the active set (Store::list): it is the graph a default view renders — tix list --show relationships, tix relationship list — so archived issues are deliberately absent from it (issue #1492). Per-issue lookups that must still resolve an archived issue go through Store::relationships_for, which reads Store::list_all.

Source

pub fn set_relationships( &self, filename: &str, targets: &[(u32, RelationKind)], ) -> Result<Issue>

Make the issue’s relationship set exactly targets, with kind.

A typed relationship is stored once, in its canonical source’s file. For a directional kind that is the declaring issue; for related, which is order-normalized, it is the lower-numbered of the pair. So this method may have to write more than one file: an edge requested from an issue that is not its canonical source belongs to the other issue’s frontmatter. That is the same ownership rule Store::link already follows, and it is what “stored once, no mirrored edge” requires.

Every target number must already resolve (NotFound otherwise) and a self-link is refused — both before anything is written (SC-005). Relationships that survive keep their stable id and their History entries; removals are recorded.

Source

pub fn relationships_for(&self, number: u32) -> Result<Vec<Relationship>>

Every relationship touching number, as the service resolves it.

This is what a read surface must render, not issue.relationships. A relationship is stored once, in its canonical source’s frontmatter, so the stored field is a partial view of one endpoint: for 2 related 1 the edge lives in #1’s file, so #2’s own relationships is empty even though #2 plainly has a relationship. Reading the raw field made tix view 2 and the TUI detail pane show nothing where the service reports one, which is a user-visible regression against the retired symmetric model — the old field was mirrored on both sides by construction.

Routing every surface through one accessor is also what keeps C-003 honest: derived relationship state is computed in exactly one place.

Source

pub fn all_relationships(&self) -> Result<Vec<Relationship>>

Every stored relationship, in deterministic order — the whole graph in one pass.

Store::relationships_for answers the same question for one issue, and each of its calls reads the whole tree and rebuilds the graph. A caller that wants every edge must therefore ask once: an N-issue loop over relationships_for is N full scans of N files, which at this repository’s size is millions of file reads for one export. This is the accessor for that caller.

Archived issues are included, exactly as Store::relationships_for includes them — the graph is the same one, built the same way, so the two agree about which edges exist.

Source§

impl Store

Source

pub fn set_status( &self, filename: &str, request: StatusChange<'_>, ) -> Result<StatusOutcome>

The status write path (WP03, C-003, FR-002, NFR-002).

Every surface that changes an issue’s column comes through here, so the transition service is on the path structurally rather than by convention: CLI (tix mv, tix edit --status), the TUI’s move and edit actions, MCP’s move_issue and update_issue, and sync’s import. A caller that judges a transition itself is a parity defect, and the two that used to are the reason this function had no production caller at all.

§The three outcomes
ResultMeaningWritten
Ok(outcome), outcome.writtenaccepted, changed columnfile + one decision
Ok(outcome), !outcome.writtenaccepted, same columnnothing (idempotent)
Err(TixError::Workflow(_))refusednothing at all

The separation is in the type, not in a runtime check: a refusal is an Err carrying the named diagnostic, so a caller cannot read it as a write, and cannot mistake unknown_column (a typo) for invalid_transition (policy) — one message shape for both would lose the distinction the contract’s shared vocabulary exists to keep (contract §6).

§Rejection is inert

Nothing is touched before the service has accepted: no issue file, no tix.toml, no History entry. The service is pure (src/workflow.rs is filesystem-free by design), and the only write in this function is the single update at the end — so there is no window in which a refusal leaves a partial change behind.

§No repair path

A file hand-edited into an inconsistent status is read, judged, and reported. This function never corrects one (C-005, decision 01M3C3XBMTR5WFV7059NXM04AJ); WP06 owns the diagnosis. An auto-repair sneaking in here would be an escalation trigger.

Routes through Store::update rather than serialising directly, so the retired links: disclosure happens on this path too. Cycle-5 Minor 9: this used to write the file itself, which drops the retired links: key because the key is not in the model — with no disclosure, since the disclosure lives in update. It was dead code reachable only from tests, so nothing caught it, and wiring it up would have silently destroyed the key on every surface at once.

Source§

impl Store

Source

pub fn mention_store(&self) -> Option<MentionStore>

The mention store, or None when [mentions] enabled = false.

One constructor for every call site — the write seam, the TUI’s indicator and the CLI’s tix mentions — so “disabled” cannot mean different things in different places: no store, no scan, no write.

The directory resolves against the project root, the same rule issues_dir follows, so a tix run from a subdirectory reads and writes the same files.

Auto Trait Implementations§

§

impl Freeze for Store

§

impl RefUnwindSafe for Store

§

impl Send for Store

§

impl Sync for Store

§

impl Unpin for Store

§

impl UnsafeUnpin for Store

§

impl UnwindSafe for Store

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoEither for T

Source§

fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ

Converts self into a Left variant of Either<Self, Self> if into_left is true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
where F: FnOnce(&Self) -> bool,

Converts self into a Left variant of Either<Self, Self> if into_left(&self) returns true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = !

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, !>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.