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

pub(super) fn epic_health(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    let reference = req_string(args, "epic")?;
    let (config, _path) = match registry_config(store) {
        Ok(loaded) => loaded,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let store = store_with_config(store, config);
    let issues = match store.list_all() {
        Ok(issues) => issues,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let index = crate::epics::EpicIndex::build(&store.config, &issues);
    let targets = match epic_reference_targets(&store.config, &store) {
        Ok(targets) => targets,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    // The same snapshot `commands::epic::show` feeds the identical read: the index
    // over every issue file, the blocked state over the relationship service's
    // active set. Read-only: nothing is written and no History entry is appended.
    let (graph_issues, done_column) = match store.graph_source() {
        Ok(source) => source,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let graph = crate::commands::graph_over(&graph_issues, &done_column);
    match index.rollup(&store.config, &reference, &targets, &graph) {
        Ok(rollup) => Ok(success_result(
            epic_health_text(&rollup),
            epic_rollup_value(&rollup),
        )),
        Err(error) => Ok(epic_refusal(error)),
    }
}

/// `health_history`: the append-only entries about a scope.
pub(super) fn health_history(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    let raw = match req_string(args, "scope") {
        Ok(raw) => raw,
        Err(()) => return Ok(proto_error()),
    };
    let Some(scope) = crate::health::HealthScope::parse(&raw) else {
        return Ok(error_result(format!(
            "invalid input: `{raw}` is not a scope; expected issue:<n>, project:<id>, \
             epic:<id>, cycle:<id> or milestone:<id>"
        )));
    };
    let _ = store;
    let document = match crate::commands::export::run_health_core(config) {
        Ok(document) => document,
        Err(error) => return Ok(error_result(crate::error::render_error(&error))),
    };
    let text = crate::commands::health::render_history(&document, &scope);
    let mut structured = health_scope_value(&document, &scope);
    structured["policy"] = health_policy_value();
    Ok(success_result(text, structured))
}

/// `health_record`: the one writer, shared with the CLI and the TUI.
pub(super) fn health_record(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    let selector = match selector(args, "issue") {
        Ok(selector) => selector,
        Err(()) => return Ok(proto_error()),
    };
    let state = match req_string(args, "state") {
        Ok(state) => state,
        Err(()) => return Ok(proto_error()),
    };
    let rationale = match req_string(args, "rationale") {
        Ok(rationale) => rationale,
        Err(()) => return Ok(proto_error()),
    };
    let actor = match opt_string(args, "actor") {
        Ok(actor) => actor,
        Err(()) => return Ok(proto_error()),
    };
    let date = match opt_string(args, "date") {
        Ok(date) => date,
        Err(()) => return Ok(proto_error()),
    };
    let over = match opt_bool(args, "override") {
        Ok(value) => value.unwrap_or(false),
        Err(()) => return Ok(proto_error()),
    };
    let confirm = match opt_bool(args, "confirm") {
        Ok(value) => value.unwrap_or(false),
        Err(()) => return Ok(proto_error()),
    };
    // An override is an explicit act about a whole scope, so it needs the same
    // confirmation the CLI's `--confirm` demands, with the same reason.
    if over && !confirm {
        return Ok(error_result(
            "refusing to override derived health without `confirm: true`: an override is \
             an explicit act about a whole scope, and it layers over the derived value \
             rather than replacing it"
                .to_string(),
        ));
    }
    let number = match store.resolve_issue(&selector) {
        Ok(issue) => issue.number,
        Err(error) => return Ok(error_result(crate::error::render_error(&error))),
    };
    let actor = actor.unwrap_or_else(crate::storage::actor_identity);
    let date = date.unwrap_or_else(crate::model::today);
    let source = if over {
        crate::health::HealthSource::Override
    } else {
        crate::health::HealthSource::Current
    };
    match crate::commands::health::record(
        store,
        crate::health::HealthScope::of_issue(number),
        &state,
        &actor,
        &date,
        &rationale,
        source,
    ) {
        Ok(applied) => {
            let stored = applied
                .issue
                .health
                .as_ref()
                .and_then(|health| health.state.clone())
                .unwrap_or_default();
            // The CLI's stdout, verbatim.
            let text = crate::commands::health::render_mutation(
                number,
                &stored,
                source,
                crate::health::HealthPolicy::v1().version(),
            );
            let (_, resolution) = crate::workflow::active_resolution(config);
            let mut structured = issue_value(&applied.issue, &resolution);
            structured["health_recorded"] = json!({
                "state": stored,
                "source": source.as_str(),
                "policy_version": crate::health::HealthPolicy::v1().version(),
                "history_entry": {
                    "kind": applied.event.kind.as_str(),
                    "timestamp": applied.event.timestamp,
                    "actor": applied.event.actor,
                    "detail": applied.event.detail,
                },
            });
            Ok(success_result(text, structured))
        }
        Err(error) => Ok(error_result(crate::error::render_error(&error))),
    }
}

/// `health_rollup`: a scope's derived health, with its contributors.
pub(super) fn health_rollup(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    let kind = match req_string(args, "scope") {
        Ok(kind) => kind,
        Err(()) => return Ok(proto_error()),
    };
    let id = match req_string(args, "id") {
        Ok(id) => id,
        Err(()) => return Ok(proto_error()),
    };
    let Some(scope) = crate::health::HealthScope::parse(&format!("{}:{}", kind.trim(), id.trim()))
    else {
        return Ok(error_result(format!(
            "invalid input: `{kind}` is not a health scope; expected issue, project, epic, \
             cycle or milestone"
        )));
    };
    let _ = store;
    let document = match crate::commands::export::run_health_core(config) {
        Ok(document) => document,
        Err(error) => return Ok(error_result(crate::error::render_error(&error))),
    };
    let text = crate::commands::health::render_rollup(&document, &scope);
    let mut structured = health_scope_value(&document, &scope);
    structured["policy"] = health_policy_value();
    Ok(success_result(text, structured))
}

/// `health_scope`: one scope's value, its derived layer and its diagnostics.
pub(super) fn health_scope(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    let scope = match health_requested_scope(store, args) {
        Ok(scope) => scope,
        Err(result) => return Ok(result),
    };
    let document = match crate::commands::export::run_health_core(config) {
        Ok(document) => document,
        Err(error) => return Ok(error_result(crate::error::render_error(&error))),
    };
    // The CLI's own text, verbatim: an agent reading MCP and a person reading the CLI
    // must see the same characters (C-007).
    let text = crate::commands::health::render_show(&document, &scope);
    let mut structured = health_scope_value(&document, &scope);
    structured["policy"] = health_policy_value();
    Ok(success_result(text, structured))
}

pub(super) fn project_health(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    let reference = req_string(args, "project")?;
    let (config, _path) = match registry_config(store) {
        Ok(loaded) => loaded,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    // The **same snapshot the CLI feeds `project_reports`**: `tix show`'s project
    // block and `tix list --show progress` both pass `store.list()` — the active
    // set. Feeding `list_all()` here would make MCP count an archived member that
    // neither CLI channel counts, so `total`/`done`/`unassigned` and the
    // `missing_references` set would differ for the same snapshot.
    let issues = match store.list() {
        Ok(issues) => issues,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    // The CLI's own read path (`commands::view::project_reports`): one index build
    // and one health read per project, over the consumed overdue/blocked facts, so
    // MCP, the CLI and the TUI read the *same value* for the same snapshot rather
    // than three computations that happen to agree (C-006, NFR-003). Read-only:
    // nothing is written and no History entry is appended.
    let registry = store_with_config(store, config);
    match crate::commands::view::project_reports(&registry, &issues) {
        Ok((index, reports)) => match index.resolve(&reference) {
            Ok(project) => match reports.get(&project.id) {
                Some(report) => Ok(success_result(
                    project_health_text(report),
                    project_health_value(report),
                )),
                None => Ok(error_result(format!(
                    "internal error: the project service returned no health value for `{}`",
                    project.id
                ))),
            },
            Err(error) => Ok(project_refusal(error)),
        },
        Err(error) => Ok(error_result(crate::error::render_error(&error))),
    }
}

pub(super) fn dispatch(
    config: &Config,
    store: &Store,
    name: &str,
    arguments: &Value,
) -> Option<ToolOutcome> {
    match name {
        "project_health" => Some(project_health(config, store, arguments)),
        "epic_health" => Some(epic_health(config, store, arguments)),
        "health_scope" => Some(health_scope(config, store, arguments)),
        "health_rollup" => Some(health_rollup(config, store, arguments)),
        "health_history" => Some(health_history(config, store, arguments)),
        "health_record" => Some(health_record(config, store, arguments)),
        _ => None,
    }
}

/// Route a declared tool name to its handler.
///
/// `None` means "declared but not dispatched" -- see [`call`]. Split out from
/// [`call`] so the registration sweep can ask this question directly instead of
/// asserting against a hand-maintained list of names, which is what let a
/// declared-but-undispatched tool survive review rounds.
/// The policy metadata every health response carries (WP07 T035).
///
/// The version is the point: a reader comparing two repositories has to be able to
/// tell whether the two numbers were derived under the same formula, so the version,
/// the precedence and the two fallback answers travel with every value.
pub(super) fn health_policy_value() -> Value {
    let policy = crate::health::HealthPolicy::v1();
    let states: Vec<&str> = crate::health::HealthState::ALL
        .iter()
        .map(|state| state.as_str())
        .collect();
    let precedence: Vec<&str> = policy
        .precedence
        .iter()
        .map(|state| state.as_str())
        .collect();
    json!({
        "version": policy.version(),
        "states": states,
        "precedence": precedence,
        "empty_scope": policy.empty_scope().as_str(),
        "missing_reference": policy.missing_reference().as_str(),
    })
}

/// One scope's structured health, from the shared document.
pub(super) fn health_scope_value(
    document: &crate::health::PortableHealth,
    scope: &crate::health::HealthScope,
) -> Value {
    let entry = document.scope(scope);
    let diagnostics: Vec<Value> = document
        .diagnostics_for(scope)
        .into_iter()
        .map(health_diagnostic_value)
        .collect();
    json!({
        "scope": scope.as_str(),
        "state": entry.map(|entry| entry.state.clone()),
        "source": entry.map(|entry| entry.source.clone()),
        "recorded": entry.and_then(|entry| entry.recorded.clone()),
        "recorded_policy_version": entry.and_then(|entry| entry.recorded_policy_version.clone()),
        "updated_at": entry.and_then(|entry| entry.updated_at.clone()),
        "events": entry.map(|entry| entry.events.iter().map(|event| json!({
            "kind": event.kind,
            "timestamp": event.timestamp,
            "actor": event.actor,
            "detail": event.detail,
        })).collect::<Vec<Value>>()),
        "rollup": entry.and_then(|entry| entry.rollup.as_ref()).map(|rollup| json!({
            "state": rollup.state,
            "policy_version": rollup.policy_version,
            "source": rollup.source,
            "contributors": rollup.contributors.iter().map(|contributor| json!({
                "number": contributor.number,
                "state": contributor.state,
                "recorded": contributor.recorded,
            })).collect::<Vec<Value>>(),
        })),
        "diagnostics": diagnostics,
    })
}

/// One diagnostic in the wire vocabulary (the same kind/context the CLI prints).
pub(super) fn health_diagnostic_value(
    diagnostic: &crate::health::PortableHealthDiagnostic,
) -> Value {
    let shared = diagnostic.to_diagnostic();
    json!({
        "kind": shared.kind.as_str(),
        "scope": shared.scope.as_str(),
        "reference": shared.reference,
        "field": shared.field,
        "target": shared.target.map(|target| target.as_str()),
        "state": shared.state,
        "message": shared.message,
        "remedy": shared.remedy,
    })
}

/// The scope a `health_scope` call names: exactly one of `issue` or `scope`.
pub(super) fn health_requested_scope(
    store: &Store,
    args: &Value,
) -> std::result::Result<crate::health::HealthScope, Value> {
    let issue = opt_selector(args, "issue").map_err(|()| proto_error())?;
    let scope = opt_string(args, "scope").map_err(|()| proto_error())?;
    match (issue, scope) {
        (Some(issue), None) => store
            .resolve_issue(&issue)
            .map(|issue| crate::health::HealthScope::of_issue(issue.number))
            .map_err(|error| error_result(crate::error::render_error(&error))),
        (None, Some(raw)) => crate::health::HealthScope::parse(&raw).ok_or_else(|| {
            error_result(format!(
                "invalid input: `{raw}` is not a scope; expected issue:<n>, project:<id>, \
                 epic:<id>, cycle:<id> or milestone:<id>"
            ))
        }),
        (None, None) => Err(error_result(
            "invalid input: name either `issue` or `scope`".to_string(),
        )),
        (Some(_), Some(_)) => Err(error_result(
            "invalid input: name exactly one of `issue` or `scope`".to_string(),
        )),
    }
}

/// A protocol error for an argument of the wrong type.
pub(super) fn proto_error() -> Value {
    error_result("invalid input: an argument has the wrong type".to_string())
}

// ---------------------------------------------------------------------------
// WP06 / T027–T031: the initiative and roadmap tools
// ---------------------------------------------------------------------------
//
// MCP is an adapter only. Every handler below reaches behaviour through
// `crate::roadmap` (or the registry's atomic boundary) and reuses the CLI
// adapter's `pub` renderers for the text channel — it owns no second query, no
// metric, no ordering rule and no registry writer. The registry is re-read from
// disk per call, exactly as the project and epic tools do it, so an initiative
// this session created is visible to the next read.