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::*;

/// Which kind of reference failed (contract §1, data-model §7).
///
/// The variants are the five kinds `resolve_references` diagnoses. `Epic` is a
/// recorded **epic id** that the registry does not hold — from an epic's `parent`
/// or from an issue's `epics` entry. `Issue` is an issue's `epics` entry that is
/// not a well-formed epic id at all (a blank, padded, or name-shaped entry): the
/// entry names no epic, so the `Epic` kind — which names a missing id — cannot
/// express it. `Project`, `Milestone` and `Cycle` are an epic record's own
/// reference fields.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum EpicReferenceKind {
    /// A recorded epic id the registry does not hold.
    Epic,
    /// An epic's project id the project registry does not hold.
    Project,
    /// An issue-side `epics` entry that is not a well-formed epic id.
    Issue,
    /// An epic's milestone name the milestone registry does not hold.
    Milestone,
    /// An epic's cycle name the cycle registry does not hold.
    Cycle,
}

impl EpicReferenceKind {
    /// The canonical snake_case spelling a surface prints.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            EpicReferenceKind::Epic => "epic",
            EpicReferenceKind::Project => "project",
            EpicReferenceKind::Issue => "issue",
            EpicReferenceKind::Milestone => "milestone",
            EpicReferenceKind::Cycle => "cycle",
        }
    }

    /// The `tix lint` diagnostic kind for a missing reference of this kind
    /// (contract `cli-surface.md` §4).
    #[must_use]
    pub fn diagnostic_kind(self) -> &'static str {
        match self {
            EpicReferenceKind::Epic => "missing_epic_reference",
            EpicReferenceKind::Project => "missing_project_reference",
            EpicReferenceKind::Issue => "missing_issue_reference",
            EpicReferenceKind::Milestone => "missing_milestone_reference",
            EpicReferenceKind::Cycle => "missing_cycle_reference",
        }
    }
}

impl fmt::Display for EpicReferenceKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// The entity that holds a failing reference (data-model §7's holder).
///
/// A reference lives either on an epic record or in an issue's `epics` list, and
/// a diagnostic that did not say which would make the user edit the wrong file.
/// One enum rather than a second diagnostic type, because a second type for one
/// direction is the second resolver C-002 forbids.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ReferenceHolder {
    /// An epic-held reference, by epic id.
    Epic(EpicId),
    /// An issue-held reference, by issue number.
    Issue(IssueKey),
}

impl fmt::Display for ReferenceHolder {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ReferenceHolder::Epic(id) => write!(f, "epic `{id}`"),
            ReferenceHolder::Issue(key) => write!(f, "issue {key}"),
        }
    }
}

/// The diagnosed outcome for a reference that does not resolve (data-model §7).
///
/// The diagnostic names the **failing reference**, never merely that something is
/// missing, so `tix lint` can point at the field to repair.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicReferenceResolution {
    /// The entity holding the reference field.
    pub holder: ReferenceHolder,
    /// Which kind of reference failed.
    pub target_kind: EpicReferenceKind,
    /// The reference exactly as recorded, retained for repair.
    pub target: String,
    /// Human-readable text, reused verbatim by lint, the CLI, the TUI and MCP.
    pub reason: String,
}

/// The single wording for a recorded epic id that does not resolve.
#[must_use]
pub fn missing_epic_reason(holder: &ReferenceHolder, target: &str) -> String {
    format!(
        "{holder} records epic reference `{target}`, which is not in the registry \
         (the reference is kept so it can be repaired)"
    )
}

/// The single wording for an issue-side entry that names no epic at all.
#[must_use]
pub fn malformed_epic_reason(issue: u32, raw: &str) -> String {
    format!(
        "issue #{issue} records epic reference `{raw}`, which is not a well-formed epic id \
         (ids are recorded, never names; the entry is kept so it can be repaired)"
    )
}

/// The single wording for an epic's project reference that does not resolve.
#[must_use]
pub fn missing_project_reason(epic: &EpicId, target: &str) -> String {
    format!(
        "epic `{epic}` references project `{target}`, which is not in the project registry \
         (the reference is kept so it can be repaired)"
    )
}

/// The single wording for an epic's milestone reference that does not resolve.
#[must_use]
pub fn missing_milestone_reason(epic: &EpicId, target: &str) -> String {
    format!(
        "epic `{epic}` references milestone `{target}`, which is not in the milestone registry \
         (the reference is kept so it can be repaired)"
    )
}

/// The single wording for an epic's cycle reference that does not resolve.
#[must_use]
pub fn missing_cycle_reason(epic: &EpicId, target: &str) -> String {
    format!(
        "epic `{epic}` references cycle `{target}`, which is not in the cycle registry \
         (the reference is kept so it can be repaired)"
    )
}

/// The ordering every diagnostic list uses: holder, then kind, then target.
pub(super) fn reference_order(
    a: &EpicReferenceResolution,
    b: &EpicReferenceResolution,
) -> std::cmp::Ordering {
    a.holder
        .cmp(&b.holder)
        .then_with(|| a.target_kind.cmp(&b.target_kind))
        .then_with(|| a.target.cmp(&b.target))
}

/// The resolvable targets of one snapshot, consumed from their owners.
///
/// This module performs no file I/O: the sets are plain in-memory collections, so
/// a caller with a fixture — a unit test, or a surface that already resolved them
/// — builds one directly. [`ReferenceTargets::from_planner`] is the optional
/// production adapter: it consumes the **existing** lookups, including
/// `Planner::milestone_names()`, which is filesystem-backed, plus
/// `Planner::cycle_names()` and the implicit `Default` cycle `Planner::cycle()`
/// answers for, and the project registry for project ids. No second milestone or
/// cycle store exists (research R2).
///
/// Each set is a `HashSet`, so a lookup is a hash probe rather than a scan: with
/// `E` epics and `p` references each, `resolve_references` stays one pass over the
/// issues plus one pass over the registry (contract §7, NFR-001).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReferenceTargets {
    /// Project ids the project registry holds, as the registry keys them.
    pub projects: HashSet<String>,
    /// Milestone names the milestone registry holds.
    pub milestones: HashSet<String>,
    /// Cycle names the cycle registry answers for.
    pub cycles: HashSet<String>,
}

impl ReferenceTargets {
    /// Consume the existing planning lookups for one [`Planner`].
    ///
    /// This is the production adapter: milestones come from
    /// [`Planner::milestone_names`] and cycles from [`Planner::cycle_names`] plus
    /// the implicit `Default` cycle [`Planner::cycle`] answers for even when no
    /// file holds it. Both sets are built once here, so the diagnosis below is a
    /// probe per reference rather than a scan per reference.
    pub fn from_planner(planner: &Planner) -> Result<Self> {
        let mut cycles: HashSet<String> = planner.cycle_names().into_iter().collect();
        if planner.cycle(DEFAULT_CYCLE_NAME).is_some() {
            cycles.insert(DEFAULT_CYCLE_NAME.to_string());
        }
        Ok(Self {
            projects: planner.config().projects.keys().cloned().collect(),
            milestones: planner.milestone_names()?.into_iter().collect(),
            cycles,
        })
    }

    /// Whether the project registry holds `id`.
    #[must_use]
    pub fn project_known(&self, id: &str) -> bool {
        self.projects.contains(id)
    }

    /// Whether the milestone registry holds `name`.
    #[must_use]
    pub fn milestone_known(&self, name: &str) -> bool {
        self.milestones.contains(name)
    }

    /// Whether the cycle registry answers for `name`.
    #[must_use]
    pub fn cycle_known(&self, name: &str) -> bool {
        self.cycles.contains(name)
    }
}

/// Every reference in the snapshot that does not resolve (contract §1, §5).
///
/// **Never** returns an error — lint consumes the list directly — and performs one
/// pass over the issue set (the index build) plus one pass over the registry
/// rather than one lookup per reference (contract §7). Each failing reference is
/// reported once, naming the holder, the kind, and the recorded target, ordered by
/// `(holder, kind, target)` so lint output is stable (C-005, contract §8).
///
/// `targets` carries what the owners resolved: the project registry, and the
/// milestone and cycle names from the `Planner` lookups (contract §2). The service
/// stores none of them.
#[must_use]
pub fn resolve_references<N: EpicNode>(
    registry: &IndexMap<String, EpicConfiguration>,
    issues: &[N],
    targets: &ReferenceTargets,
) -> Vec<EpicReferenceResolution> {
    let index = EpicIndex::build_from_registry(registry, issues);
    let mut out: Vec<EpicReferenceResolution> = index.missing().to_vec();
    for epic in index.epics() {
        out.extend(index.missing_for(&epic.id, targets));
    }
    out.sort_by(reference_order);
    out
}

/// Find an issue in the snapshot by its number.
pub(super) fn find_issue<N: EpicNode>(issues: &[N], selector: u32) -> Result<&N, EpicError> {
    issues
        .iter()
        .find(|issue| issue.number() == selector)
        .ok_or(EpicError::UnknownIssue { selector })
}

/// Produce the reference change for a **resolved** epic id.
pub(super) fn reference_resolved<N: EpicNode>(
    issues: &[N],
    selector: u32,
    id: &EpicId,
) -> Result<EpicChange, EpicError> {
    let issue = find_issue(issues, selector)?;
    let current = issue.epic_ids();
    let key = IssueKey::new(selector);
    if current.iter().any(|stored| stored == id.as_str()) {
        return Ok(EpicChange {
            issue: key,
            epic_ids: current.to_vec(),
            event: None,
        });
    }
    let mut next = current.to_vec();
    next.push(id.to_string());
    Ok(EpicChange {
        issue: key,
        epic_ids: next,
        event: Some(EpicEvent {
            kind: EpicEventKind::EpicReferenced,
            epic_id: id.to_string(),
            detail: reference_detail(id.as_str(), true),
        }),
    })
}

/// Reference an epic from an issue, producing the change and its History event.
///
/// Idempotent: an already-present reference changes nothing and yields **no**
/// event (contract §5). Refuses an unknown epic or an unknown issue; a refusal
/// produces no change and no event.
pub fn reference_epic<N: EpicNode>(
    config: &Config,
    issues: &[N],
    issue_selector: u32,
    epic_reference: &str,
) -> Result<EpicChange, EpicError> {
    let id = resolve_epic_id(&config.epics, epic_reference)?;
    reference_resolved(issues, issue_selector, &id)
}

/// Remove an issue's reference to an epic.
///
/// Drops only the reference: it never deletes or modifies the issue (C-006).
/// Dereferencing an absent reference is an idempotent no-op with no event. The
/// epic is **not** required to exist — removing a stale reference is the repair
/// path C-005 promises — so the only refusal is an unknown issue.
///
/// `epic_id` is compared against the stored list **verbatim**: unlike
/// [`reference_epic`], an epic *name* is not resolved here. That is deliberate —
/// the stored entries are ids, and a caller must resolve a name to an id first
/// (via [`resolve_epic_id`]). A name therefore matches nothing and is an inert
/// no-op, which keeps the repair path working for an id that is no longer a valid
/// [`EpicId`] shape or no longer in the registry.
pub fn dereference_epic<N: EpicNode>(
    issues: &[N],
    issue_selector: u32,
    epic_id: &str,
) -> Result<EpicChange, EpicError> {
    let issue = find_issue(issues, issue_selector)?;
    let current = issue.epic_ids();
    let key = IssueKey::new(issue_selector);
    if !current.iter().any(|stored| stored == epic_id) {
        return Ok(EpicChange {
            issue: key,
            epic_ids: current.to_vec(),
            event: None,
        });
    }
    let next: Vec<String> = current
        .iter()
        .filter(|stored| stored.as_str() != epic_id)
        .cloned()
        .collect();
    Ok(EpicChange {
        issue: key,
        epic_ids: next,
        event: Some(EpicEvent {
            kind: EpicEventKind::EpicDereferenced,
            epic_id: epic_id.to_string(),
            detail: reference_detail(epic_id, false),
        }),
    })
}

/// Reference each selector independently, reporting every outcome.
///
/// One rejection must not abort the rest (contract §5): the returned vector has
/// one entry per selector, in selector order, and an unknown epic refuses each
/// entry rather than the whole call.
#[must_use]
pub fn bulk_reference<N: EpicNode>(
    config: &Config,
    issues: &[N],
    epic_reference: &str,
    issue_selectors: &[u32],
) -> Vec<BulkEpicOutcome> {
    let resolved = resolve_epic_id(&config.epics, epic_reference);
    issue_selectors
        .iter()
        .map(|&selector| {
            let outcome = match &resolved {
                Ok(id) => reference_resolved(issues, selector, id),
                Err(err) => Err(err.clone()),
            };
            BulkEpicOutcome { selector, outcome }
        })
        .collect()
}