tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The single owner of epic behaviour (mission `epics-work-items-01M3BYW4`,
//! WP02 — `contracts/epic-service.md`).
//!
//! Everything about an epic lives here: identity and resolution, the registry
//! lifecycle (create / edit / delete / list), the derived hierarchy with its
//! dedicated iterative cycle walk, reference diagnosis across all five kinds,
//! derived membership and the index that makes an epic filter a bucket lookup,
//! and — in exactly one place — the epic rollup (completion, child counts,
//! weight, blocked work). A second identity resolver, a second membership index,
//! a second progress implementation, a second relationship mechanism, or a
//! second milestone/cycle store is the divergence the mission's charter
//! escalates (C-002, C-008).
//!
//! ## What this module deliberately does not do
//!
//! - **It performs no file I/O** — no `Store`, no `std::fs`, no `tempfile`. It
//!   takes an in-memory [`Config`] and an in-memory issue set and returns values;
//!   the write path is storage's (WP03) and this module owns the rule. Every
//!   operation is therefore testable with no repository at all (T012 step 4).
//!   [`ReferenceTargets::from_planner`] is the one optional adapter that consumes
//!   another owner's filesystem-backed lookup; the sets it produces are plain
//!   in-memory values, and a caller can supply them directly instead.
//! - **It does not know about the issue model.** [`EpicNode`] is the minimum an
//!   issue must expose — its number, title, status, and its `epics` id list — and
//!   WP03 implements it for [`crate::model::Issue`], exactly as the project
//!   service's `ProjectNode` and the hierarchy service's `HierarchyNode` are
//!   implemented there. WP02 is forbidden from touching `src/model.rs`, so the
//!   service is defined against the shape WP03 provides. Because the trait has
//!   **no label accessor**, membership is structurally incapable of telling a
//!   `task`-labelled issue from any other — that is the C-001 guard, not a
//!   convention.
//! - **It never mutates or returns a whole `Issue`.** [`reference_epic`] and
//!   [`dereference_epic`] return an [`EpicChange`]: the resulting reference list
//!   plus the History event to append. Storage applies it. That boundary is what
//!   keeps this module model-free.
//! - **It stores no membership.** An epic's members are derived by inverting the
//!   issues' reference lists and are never persisted (data-model §4, C-002). The
//!   registry record carries no member list at all.
//! - **It does not recompute blocked state.** [`EpicIndex::rollup`] consumes
//!   source issue 169's derived [`RelationshipGraph::blocked_state`] and reuses
//!   its `reason` text verbatim (research R4). Re-deriving blocked work here
//!   would create a second owner for state another Mission owns.
//! - **It does not reimplement milestone or cycle lookup.** [`ReferenceTargets`]
//!   consumes `Planner::milestone_names()`, `Planner::cycle_names()` and
//!   `Planner::cycle()` (`src/planning.rs`) through
//!   [`ReferenceTargets::from_planner`]; no second store is introduced (research
//!   R2). Project references are checked against the project registry itself,
//!   never by reversing this module's index (research R6).
//! - **It does not share the issue-parent cycle walk.** An epic chain and an
//!   issue-parent chain are different semantics (C-002); the walk below *follows
//!   the same shape* as `crate::hierarchy`'s and is dedicated to epics.
//!
//! ## The index is built once per read
//!
//! [`EpicIndex`] is the derived view over one snapshot. It is built once
//! ([`EpicIndex::build`]) and every read — [`EpicIndex::membership`],
//! [`EpicIndex::resolve`], [`EpicIndex::hierarchy`], [`EpicIndex::rollup`],
//! [`EpicIndex::missing`] — reads that one value rather than rebuilding. The
//! contract's free operations each build exactly one index per call, so a filter
//! is a bucket lookup and a rollup never re-scans the issue set (NFR-001).
//!
//! ## Ordering is deterministic
//!
//! Members are ordered by issue number, epics by declaration order, child lists
//! by declaration order, ancestors root-first, and diagnostics by
//! `(holder, kind, target)`, so repeated reads of the same snapshot are
//! identical (contract §8).

use std::collections::{HashMap, HashSet};
use std::fmt;

use indexmap::IndexMap;

use crate::config::{Config, EpicConfiguration, EpicId, ProjectId};
use crate::cycle_store::DEFAULT_CYCLE_NAME;
use crate::error::{Result, TixError};
use crate::planning::{Planner, Progress};
use crate::relationships::RelationshipGraph;

mod index;
mod references;
mod registry;
mod rollup;
pub use index::*;
pub use references::*;
pub use registry::*;
pub use rollup::*;

pub use crate::projects::{IssueKey, MemberRef, Patch};

/// The minimum an issue must expose for epic derivation.
///
/// WP03 implements this for [`crate::model::Issue`]. Keeping it a trait is what
/// lets this module be written and tested without `src/model.rs`, which WP03
/// owns. There is deliberately **no** label accessor: C-001 requires a
/// `task`-labelled issue to be an ordinary member, and a trait that cannot see
/// labels cannot make it anything else.
pub trait EpicNode {
    /// The issue's number, which is its identity within one snapshot.
    fn number(&self) -> u32;
    /// The issue's title, used only for the derived display label.
    fn title(&self) -> &str;
    /// The issue's raw status, compared against the board's completion column.
    fn status(&self) -> &str;
    /// The stored epic ids, in the order the file carries them.
    fn epic_ids(&self) -> &[String];
}

// ---------------------------------------------------------------------------
// T006 — the Epic view, resolution, and lifecycle
// ---------------------------------------------------------------------------

/// The id a **dereference** should match for `reference` (WP09 item B).
///
/// One owner for the resolve-or-keep-verbatim classification the repair path
/// needs: a reference that names a live epic resolves to its stable id, and
/// anything else — a stale id, a malformed one, or a name-shaped string the
/// registry no longer holds — is passed through **verbatim** so
/// `tix epic remove` can still drop it (C-005). A second spelling of this rule
/// would let one surface refuse a removal another surface performs.
///
/// The callers are `commands::epic::dereference_from_epic`,
/// `commands::epic::plan_edit_epics`, and MCP's `epic_dissociate_issues`, so the
/// three cannot drift.
#[must_use]
pub(crate) fn dereference_needle(
    registry: &IndexMap<String, EpicConfiguration>,
    reference: &str,
) -> String {
    resolve_epic_id(registry, reference).map_or_else(|_| reference.to_string(), |id| id.to_string())
}

// ---------------------------------------------------------------------------
// T007 — the derived index and membership
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// T008 — the dedicated iterative epic-chain walk and hierarchy navigation
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// T009 — reference diagnosis across all five kinds
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// T010/T011 — the typed rollup: the one progress computation (FR-003, C-008)
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// T008 — association mutations, with History events
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests;