tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

/// One member holding an open blocker, with the owner's reason text verbatim.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlockedMember {
    /// The blocked member's number.
    pub issue: IssueKey,
    /// `#<number> <title>`, from the shared renderer in `hierarchy`.
    pub display: String,
    /// The reason text [`RelationshipGraph::blocked_state`] produced.
    pub reason: String,
}

/// An epic's completion progress (data-model §6, FR-003).
///
/// Mirrors the hierarchy rollup ([`crate::hierarchy::ParentRollup`]): the counts
/// spelled out for a listing, plus the typed [`Progress`] value so CLI, TUI and
/// MCP results can be compared **by value** (NFR-003). [`Progress`] is the
/// planner's, reused rather than reinvented (research R3).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicProgress {
    /// Members in the board's **configured** completion column.
    pub done: usize,
    /// Members.
    pub total: usize,
    /// The typed completion value, reused from the planner.
    pub progress: Progress,
}

/// The weight-aware figure of an epic (data-model §6).
///
/// **A member with no weight is excluded rather than counted as zero** — the
/// shape WP01 settled and `data-model.md` §6 records. The weighted population is
/// the epic's **child epics**, each contributing its own `weight`; a child counts
/// as done when it has at least one member and every one of them is complete. A
/// child with no weight, like a member issue (issues carry no weight — research
/// R3), contributes nothing and is counted in `unweighted_children` so the
/// exclusion is visible rather than silent. `own_weight` reports the epic's own
/// weight, which is what a **parent** epic's figure consumes; this epic's own
/// figure never includes it, so no number is counted twice.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WeightedProgress {
    /// This epic's own weight, `None` when unset.
    pub own_weight: Option<i64>,
    /// Children that carry a weight and therefore contribute.
    pub weighted_children: usize,
    /// Children excluded because they carry no weight.
    pub unweighted_children: usize,
    /// `Σ` weights of the weighted children that are complete.
    pub done_weight: i64,
    /// `Σ` weights of the weighted children — the weighted population.
    pub total_weight: i64,
    /// `done_weight` as a floored integer percentage of `total_weight`;
    /// `None` when the weighted population is empty, so "no weight to report"
    /// stays distinguishable from "0% done".
    pub percent: Option<u32>,
}

/// The typed value the `rollup` operation returns (contract §1, data-model §6).
///
/// One value, carrying the epic's identity, its completion, its child count, its
/// weight-aware figure, its blocked members, and its unresolvable references, so a
/// CLI, TUI or MCP surface renders it rather than re-deriving any figure (C-008).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicRollup {
    /// The epic the figures describe.
    pub epic_id: EpicId,
    /// The epic's display label, so a surface needs no second lookup.
    pub name: String,
    /// Completion figures, computed once here.
    pub progress: EpicProgress,
    /// Child epics, from the derived hierarchy. Never a stored count.
    pub child_count: usize,
    /// The weight-aware figure.
    pub weighted_progress: WeightedProgress,
    /// Members with an open blocker that are not done.
    pub blocked: usize,
    /// Those members, carrying the blocker owner's reason text verbatim.
    pub blocked_members: Vec<BlockedMember>,
    /// This epic's references that no longer resolve (C-005).
    pub missing_references: Vec<EpicReferenceResolution>,
}

/// The two reference mutations, as the `## History` section records them.
///
/// Declared here rather than in `model::HistoryEventKind` because WP02 may not
/// edit `src/model.rs`; WP03 maps these onto that enum, exactly as the project
/// and hierarchy missions' kinds are mapped.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EpicEventKind {
    /// An issue gained an epic reference.
    EpicReferenced,
    /// An issue lost an epic reference.
    EpicDereferenced,
}

impl EpicEventKind {
    /// The canonical snake_case spelling the History section will carry.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            EpicEventKind::EpicReferenced => "epic_referenced",
            EpicEventKind::EpicDereferenced => "epic_dereferenced",
        }
    }
}

/// One reference mutation, ready for storage to append to `## History`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicEvent {
    /// Which of the two mutations this is.
    pub kind: EpicEventKind,
    /// The epic id exactly as recorded. A `String`, not an [`EpicId`], because a
    /// dereference may remove a stored id that is not a valid shape.
    pub epic_id: String,
    /// The rendered detail line, from [`reference_detail`].
    pub detail: String,
}

/// The outcome of a reference mutation: the resulting list plus the event.
///
/// The service produces the event and **does not write**; storage applies the list
/// and appends the event, so the atomicity rule lives in one place (contract §4).
/// It never returns a whole `Issue`, which is what keeps this module model-free.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicChange {
    /// The issue whose references changed.
    pub issue: IssueKey,
    /// The reference list after the change, first-seen order preserved.
    pub epic_ids: Vec<String>,
    /// The event to append, or `None` when the mutation was a no-op.
    ///
    /// `None` for referencing an already-present epic or dereferencing an absent
    /// one: neither is a change, and recording one would put an entry in the audit
    /// trail for nothing (contract §5).
    pub event: Option<EpicEvent>,
}

/// The per-issue outcome of a bulk reference (contract §1).
///
/// One entry per selector, each carrying its own success or refusal, so one
/// rejection is observable beside the rest rather than aborting them (contract
/// §5).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BulkEpicOutcome {
    /// The selector this entry answers.
    pub selector: u32,
    /// The change or the refusal for that selector.
    pub outcome: Result<EpicChange, EpicError>,
}

/// The ordered rollup of an epic, by id or name (contract §1).
pub fn rollup<N: EpicNode>(
    config: &Config,
    issues: &[N],
    reference: &str,
    targets: &ReferenceTargets,
    graph: &RelationshipGraph<'_>,
) -> Result<EpicRollup, EpicError> {
    EpicIndex::build(config, issues).rollup(config, reference, targets, graph)
}