tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#![allow(clippy::wildcard_imports)]
//! The MCP tool surface (WP04; WP08 added the triage tools): the static registry,
//! strict argument extraction, the FR-014 structured result shape, and the
//! handlers wired to the WP02/WP03 command cores (plus `Issue::add_comment` for
//! `comment_on_issue`, which has no CLI counterpart — C-003).
//!
//! Two layers stay strictly separate (`contracts/mcp-tools.md` §4, T021):
//! - *protocol* errors (`-32601` unknown tool, `-32602` type-invalid
//!   arguments) are returned as JSON-RPC errors by [`call`];
//! - *domain* failures (bad selector, unknown column, missing external CLI…)
//!   are tool-level results with `isError: true` and the CLI's exact error
//!   string verbatim (`C-006`) — never a JSON-RPC error (`NFR-002`), so a
//!   failing tool never kills the server.

use std::collections::BTreeSet;
use std::path::Path;
use std::sync::OnceLock;

use serde_json::{Value, json};

use crate::analytics::chart::{
    ChartExport, FORMAT as CHART_FORMAT, SCHEMA_VERSION as CHART_SCHEMA_VERSION, precision_of,
};
use crate::analytics::diagnostics::AnalyticsDiagnosticKind;
use crate::analytics::events::ScopeKind;
use crate::analytics::projection::{PROJECTION_FORMULA_VERSION, ProjectionStatus};
use crate::analytics::series::{Coverage, MAX_SERIES_DAYS, Mode};
// The unit table `estimate` mode converts through, read by the same function the fold
// reads it with, so a surface validating a `--unit` asks the table the fold will ask.
use crate::analytics::units::declared_units;
// The CLI's own scope/period/mode/unit request shape, so the analytics tools parse a
// request the way `tix analytics` does rather than through a second argument reader.
use crate::bulk;
use crate::cli::AnalyticsScopeArgs;
// The **data** module keeps the plain name `bulk`; the CLI surface is aliased, because
// both are called `bulk` and importing both plainly is a shadowing error rather than
// a choice. Same split as `ui::bulk`.
use crate::commands::bulk as cmd_bulk;
// WP07 (mission `cycle-capacity-planning-01M3C85C`): the capacity CLI adapter, aliased
// like every other `cmd_*`. MCP calls its scope-identity and formatting helpers rather
// than keeping a second copy — one `parse_scope_kind`, one `scope_label`, one
// `entries_matching`/`one_entry`, one set of `-`-rule formatters, one calculation and
// evidence text — so the two surfaces cannot drift (C-003/C-007/NFR-003).
use crate::commands::capacity as cmd_capacity;
use crate::commands::{
    create, delete, edit, epic as cmd_epic, export, import, lint, list, mv, parse_priority,
    parse_selector, roadmap as cmd_roadmap, search, status, templates as cmd_templates, view,
};
// WP06: the saved-view CLI adapter. Aliased as `cmd_views` because `view` above is
// the *issue* view command (`tix view <n>`), and the saved-view adapter is a
// different module. The MCP view tools reuse this adapter's argument shaping and
// rendering rather than keeping a second copy: one plan builder, one scope builder,
// one diagnostic rendering, so the two surfaces cannot drift (C-007/NFR-003).
use crate::commands::views as cmd_views;
// WP08: the recurring CLI adapter. Aliased as `cmd_recurring` for the same reason
// `cmd_bulk` and `cmd_triage` are, and *used*: MCP calls its resolver and its date
// context builder rather than owning a second one (C-008), so a series reference
// and a `--date`/`date` argument mean the same thing on both surfaces.
use crate::commands::recurring as cmd_recurring;
// WP08: the triage surface. `cmd_triage` is the CLI's own module, aliased for the
// same reason `cmd_bulk` is — and *used*, not re-implemented: `triage_list` and
// `triage_status` return its rendered text verbatim (NFR-003).
use crate::commands::triage as cmd_triage;
// OpenSpec change `issue-mentions`: the MCP mention tools are thin adapters over the
// CLI core, so both surfaces list and resolve the same records (`NFR-003`).
use crate::commands::mentions as cmd_mentions;
// Mission `per-issue-time-tracking-01M3C9A4` (WP04/WP06): the time CLI adapter,
// aliased like every other `cmd_*`. The MCP time tools answer with this adapter's own
// presentation rather than keeping a second copy — the refusal block, the scope block,
// the `-`-rule formatters, the breakdown vocabulary with its refusal sentence, the
// unconfirmed-delete sentence and the four mutation sentences, and the archived-selector
// refusal `time_total` shares with `tix time total` — so the two surfaces cannot drift
// (C-006).
use crate::commands::time as cmd_time;
// WP07 (mission `burndown-scope-analytics-01M3C9J1`): the burndown/scope-analytics
// surface, aliased like every other `cmd_*` and *used*. The tools below return this
// module's own report builders' text verbatim — the builders the CLI's three `tix
// analytics` verbs call — and the chart document those builders returned as the
// structured answer, so MCP owns no daily fold, no estimate conversion, no projection
// and no chart document of its own (C-008/NFR-003). A second rendering here is the
// defect the six deleted copies were, and there is nothing left to delete.
use crate::commands::analytics as cmd_analytics;
use crate::config::{Config, InitiativeConfiguration};
// WP08 (mission `repeating-cycle-schedules-01M3C7W1`): the cycle-schedule surface.
// The MCP peer of `tix cycle schedule …`, over the same services the CLI adapter
// calls. The types are aliased wherever the recurring surface already claims the
// plain name (`CycleLifecycleAction` vs `LifecycleAction`, `generate_cycles` vs the
// recurring `generate`), so a reader cannot confuse the two families.
use crate::cycle_schedule::config::{CycleScheduleConfiguration, CycleScheduleId};
use crate::cycle_schedule::evaluate::evaluate as evaluate_cycle_schedules;
use crate::cycle_schedule::generate::{
    GenerationOutcome as CycleGenerationOutcome, GenerationReport as CycleGenerationReport,
    GenerationRequest as CycleGenerationRequest, generate as generate_cycles,
};
use crate::cycle_schedule::identity::{
    CycleReference, GeneratedCycle, GeneratedCycleId, GenerationCursor, ScheduleIndex, cadence_rule,
};
use crate::cycle_schedule::lifecycle::{
    LifecycleAction as CycleLifecycleAction, LifecycleOutcome as CycleLifecycleOutcome,
    LifecycleRequest as CycleLifecycleRequest, apply as apply_cycle_lifecycle,
};
use crate::cycle_schedule::registry::{self, ScheduleDraft, ScheduleEdit};
use crate::cycle_schedule::snapshot::{CycleSnapshot, CycleSnapshotStore};
// WP07 (mission `cycle-capacity-planning-01M3C85C`): the capacity surface. Every
// handler answers from the **shared** library — `CapacityScope`'s identity adapter,
// WP05's `calculation_for`/`snapshot_for` entry points, WP04's immutable snapshot
// reader and WP03's warning vocabulary — so MCP owns no formula, no conversion, no
// status comparison and no snapshot writer (C-007/NFR-003). A capacity scope is folded
// by the library, so a generated instance's stable id and its name are one scope here
// exactly as they are in the CLI (C-003).
use crate::capacity::calculate::{CapacityCalculation, CapacityScope};
use crate::capacity::config::CapacityScopeKind;
use crate::capacity::snapshot::CapacitySnapshot;
use crate::capacity::warning::{CapacityWarning, CapacityWarningKind};
use crate::capacity::{CapacityEntry, resolve_cycle, schedule_index};
use crate::cycle_store::CycleStore;
use crate::error::Result as TixResult;
use crate::model::{History, HistoryEventKind, Issue, Status};
use crate::records::RecordStore;
use crate::recurrence::detect::{Detection, detect};
use crate::recurrence::generate::{GenerationOutcome, GenerationRequest, generate};
use crate::recurrence::lifecycle::{
    LifecycleAction, LifecycleDiagnostic, LifecycleOutcome, LifecycleRequest,
    apply as apply_lifecycle,
};
use crate::recurrence::rule::{DateKey, IntervalUnit, RuleContext, RuleKind};
use crate::recurrence::series::{KeyState, OccurrenceKey, Series, SeriesId, SeriesIndex};
use crate::search::{Corpus, SearchHit};
use crate::storage::Store;
use crate::templates::{
    DeleteConfirmed as TemplateDeleteConfirmed, OPTIONS_SOURCES, Template, TemplateDefaults,
    TemplateDefinition, TemplateDetail, TemplatePrompt, TemplateReference, TemplateScope,
    TemplateService, TemplateWriteError, resolve,
};
// WP06 (mission `per-issue-time-tracking-01M3C9A4`): the time surface. Every read
// answers from the **shared** time DTOs — `TimeSnapshot`'s totals, breakdowns and
// audit, `TimeDiagnostic`'s findings — and every mutation goes through
// `service::apply`, the one seam that validates, stages the projection and the single
// event, and writes once. MCP owns no sum, no filter rule, no entry validation, no
// `## History` grammar and no writer, so the CLI, the TUI, MCP and the portable
// mapping cannot disagree about a minute, a date or a refusal (C-006, NFR-003).
use crate::time_tracking::aggregate::{TimeSnapshot, TimeTotal, TimeTotalState};
use crate::time_tracking::diagnostics::TimeDiagnostic;
use crate::time_tracking::entry::TimeEntry;
use crate::time_tracking::filters::TimeFilter;
use crate::time_tracking::service::{
    NewTimeEntry, TimeEntryEdit, TimeMutationRequest, apply as apply_time,
};
use crate::triage::{DecisionKind, TriageDecision, TriagePlan, batch, inbox};
use crate::views::divergence::{ActiveViewMarker, ViewDivergence, ViewSurface};
use crate::views::plan::{ViewApplyResult, ViewPlan};
use crate::views::registry::{
    SavedView, ViewDiagnostic, ViewDiagnosticKind, ViewId, ViewRefusal, ViewScope, ViewWriteError,
};
use crate::views::service::{DeleteConfirmed, ViewAuditEvent, ViewDefinition, ViewService};
// WP06 / T027–T031: the initiative and roadmap tools. Aliased so the *CLI adapter*
// (`cmd_roadmap`, imported above) and the *service* (`crate::roadmap`) are not
// confused: the handlers call the service and reuse the adapter's renderers, never
// a second rule or a second rendering (C-007/NFR-003).
use crate::roadmap::registry::{AssociationKind, InitiativeEdit, NewInitiative};
use crate::roadmap::{
    InitiativeSummary, ProgressValue, RoadmapDiagnostic, RoadmapFilter, RoadmapProjection,
    RoadmapRow, RowReference, SummaryContribution,
};

use super::protocol;

// ---------------------------------------------------------------------------
// T019 – registry + argument helpers
// ---------------------------------------------------------------------------

/// A tool's static metadata. `input_schema` mirrors `contracts/mcp-tools.md`
/// §3 exactly (property names, types, `required`, `additionalProperties`).
pub(crate) struct ToolDescriptor {
    pub(crate) name: &'static str,
    description: &'static str,
    input_schema: Value,
}

/// A handler's argument type errors are protocol failures (`-32602 Invalid params`);
/// handlers use [`ToolOutcome`] so a failed core call becomes an `isError` result
/// rather than propagating as a protocol error.
type ToolOutcome = std::result::Result<Value, ()>;

/// A handler's **inner** step: either a value, or a finished outcome (either a
/// protocol error or a tool result).
///
/// Split from [`ToolOutcome`] so a helper that can produce *either* kind of refusal
/// has a return type that carries both, and the handler's final return type stays the
/// one the dispatch table expects.
type ToolOutcome2<T> = std::result::Result<T, ToolOutcome>;

/// The inner-step form of a **protocol** error: a `-32602`.
///
/// Named because `Err(Err(()))` is unreadable and the two ways an inner step can fail
/// are the exact thing this alias exists to keep apart. A malformed argument is a
/// protocol error; a well-formed request the batch refuses is a tool result.
fn proto() -> ToolOutcome {
    Err(())
}

/// The inner-step form of a **refusal**: a finished tool result carrying the reason.
fn refuse(text: String) -> ToolOutcome {
    Ok(error_result(text))
}

mod registry_analytics;
mod registry_bulk;
mod registry_capacity;
mod registry_cycles;
mod registry_epics;
mod registry_health;
mod registry_initiatives;
mod registry_issues;
mod registry_mentions;
mod registry_projects;
mod registry_recurring;
mod registry_relationships;
mod registry_roadmap;
mod registry_sync;
mod registry_templates;
mod registry_time;
mod registry_triage;
mod registry_views;

type ViewSvc<'a> = ViewService<'a, BTreeSet<String>>;

const NO_INBOX: &str =
    "`tix triage` needs a `[triage]` section in tix.toml, and this project has none";

const DELETE_CONFIRMATION: &str = "deleting a view needs --confirm: it removes the saved definition, never its issues or comments";

pub(crate) fn registry() -> &'static [ToolDescriptor] {
    static REGISTRY: OnceLock<Vec<ToolDescriptor>> = OnceLock::new();
    REGISTRY.get_or_init(|| {
        let mut tools = Vec::new();
        tools.extend(registry_analytics::descriptors());
        tools.extend(registry_bulk::descriptors());
        tools.extend(registry_capacity::descriptors());
        tools.extend(registry_cycles::descriptors());
        tools.extend(registry_epics::descriptors());
        tools.extend(registry_health::descriptors());
        tools.extend(registry_initiatives::descriptors());
        tools.extend(registry_issues::descriptors());
        tools.extend(registry_mentions::descriptors());
        tools.extend(registry_projects::descriptors());
        tools.extend(registry_recurring::descriptors());
        tools.extend(registry_relationships::descriptors());
        tools.extend(registry_roadmap::descriptors());
        tools.extend(registry_sync::descriptors());
        tools.extend(registry_templates::descriptors());
        tools.extend(registry_time::descriptors());
        tools.extend(registry_triage::descriptors());
        tools.extend(registry_views::descriptors());
        tools
    })
}

/// The descriptors serialized for the `tools/list` result (`FR-002`).
pub fn list() -> Vec<Value> {
    registry()
        .iter()
        .map(|t| {
            json!({
                "name": t.name,
                "description": t.description,
                "inputSchema": t.input_schema,
            })
        })
        .collect()
}

/// Handle a `tools/call` for one of the registered tools.
///
/// - unknown tool name → `-32601 Method not found`;
/// - strict argument type mismatch → `-32602 Invalid params`;
/// - everything else returns the *tool result* (success or `isError`), never
///   a JSON-RPC error.
pub fn call(
    config: &Config,
    store: &Store,
    id: protocol::Id,
    name: &str,
    args: &Value,
) -> std::result::Result<Value, protocol::ErrorResponse> {
    if !registry().iter().any(|t| t.name == name) {
        return Err(protocol::method_not_found(id));
    }
    let arguments = if args.is_null() { &Value::Null } else { args };
    // `dispatch` returns `None` for a name that is DECLARED but has no arm --
    // the one failure mode a `match` over names can have that the registry
    // membership check above cannot catch. That used to be `unreachable!`, which
    // turned a registration mistake into a server panic on the first call, and
    // made the mistake invisible until an agent happened to try that tool.
    match dispatch(config, store, name, arguments) {
        Some(outcome) => outcome.map_err(|()| protocol::invalid_params(id)),
        None => Ok(error_result(format!(
            "internal error: tool `{name}` is declared in the registry but has no dispatch arm"
        ))),
    }
}

mod handlers_analytics;
mod handlers_bulk;
mod handlers_capacity;
mod handlers_cycles;
mod handlers_epics;
mod handlers_health;
mod handlers_initiatives;
mod handlers_issues;
mod handlers_mentions;
mod handlers_projects;
mod handlers_recurring;
mod handlers_relationships;
mod handlers_roadmap;
mod handlers_sync;
mod handlers_templates;
mod handlers_time;
mod handlers_triage;
mod handlers_views;

mod args;
mod resolve;
mod schemas;
mod template_values;
mod values;
mod values_epics;

#[allow(clippy::wildcard_imports)]
use args::*;
#[allow(clippy::wildcard_imports)]
use handlers_cycles::*;
#[allow(clippy::wildcard_imports)]
use handlers_health::*;
#[allow(clippy::wildcard_imports)]
use handlers_recurring::*;
#[allow(clippy::wildcard_imports)]
use handlers_roadmap::*;
#[allow(clippy::wildcard_imports, unused_imports)]
use handlers_time::*;
#[allow(clippy::wildcard_imports)]
use resolve::*;
#[allow(clippy::wildcard_imports)]
use schemas::*;
#[allow(clippy::wildcard_imports)]
use template_values::*;
#[allow(clippy::wildcard_imports)]
use values::*;
#[allow(clippy::wildcard_imports)]
use values_epics::*;

fn dispatch(config: &Config, store: &Store, name: &str, arguments: &Value) -> Option<ToolOutcome> {
    handlers_analytics::dispatch(config, store, name, arguments)
        .or_else(|| handlers_bulk::dispatch(config, store, name, arguments))
        .or_else(|| handlers_capacity::dispatch(config, store, name, arguments))
        .or_else(|| handlers_cycles::dispatch(config, store, name, arguments))
        .or_else(|| handlers_epics::dispatch(config, store, name, arguments))
        .or_else(|| handlers_health::dispatch(config, store, name, arguments))
        .or_else(|| handlers_initiatives::dispatch(config, store, name, arguments))
        .or_else(|| handlers_issues::dispatch(config, store, name, arguments))
        .or_else(|| handlers_mentions::dispatch(config, store, name, arguments))
        .or_else(|| handlers_projects::dispatch(config, store, name, arguments))
        .or_else(|| handlers_recurring::dispatch(config, store, name, arguments))
        .or_else(|| handlers_relationships::dispatch(config, store, name, arguments))
        .or_else(|| handlers_roadmap::dispatch(config, store, name, arguments))
        .or_else(|| handlers_sync::dispatch(config, store, name, arguments))
        .or_else(|| handlers_templates::dispatch(config, store, name, arguments))
        .or_else(|| handlers_time::dispatch(config, store, name, arguments))
        .or_else(|| handlers_triage::dispatch(config, store, name, arguments))
        .or_else(|| handlers_views::dispatch(config, store, name, arguments))
}

#[cfg(test)]
mod tests;