tix-tracker 0.11.0

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

/// `analytics_burndown`: the shared daily series, in count or estimate mode.
///
/// The text is `tix analytics burndown`'s stdout exactly — both call
/// [`cmd_analytics::burndown_report`] — and the structure is the document that text was
/// rendered from: the scope, period, snapshot binding, mode, unit and formula version,
/// the ordered daily points with a `null` for every value the record cannot support, the
/// coverage tally and the findings. Read-only, and refused in the CLI's own words when
/// the fold answers no day at all.
pub(super) fn analytics_burndown(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    if let Some(value) = reject_hosted_source(args) {
        return Ok(value);
    }
    let request = analytics_args(args)?;
    match cmd_analytics::burndown_report(config, store, &request) {
        Ok(report) => Ok(success_result(
            report.text,
            analytics_document_value(&report.document),
        )),
        Err(error) => Ok(error_result(crate::error::render_error(&error))),
    }
}

/// `analytics_export`: WP04's deterministic chart document, as its own JSON.
///
/// The text is the document's pretty JSON — the bytes `tix analytics export` writes to
/// stdout — and the structure is that same document parsed as an object, so a client may
/// either store the bytes or read the fields. The CLI's `--output` path is deliberately
/// not an argument here: the document is returned rather than written, and this surface
/// opens no file. `mode` is required, exactly as the CLI requires `--mode`.
pub(super) fn analytics_export(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    if let Some(value) = reject_hosted_source(args) {
        return Ok(value);
    }
    let request = analytics_args(args)?;
    let as_of = opt_string(args, "as_of")?;
    match cmd_analytics::export_document(config, store, &request, as_of.as_deref()) {
        Ok(document) => match document.to_json_pretty() {
            Ok(json) => Ok(success_result(json, analytics_document_value(&document))),
            Err(error) => Ok(error_result(crate::error::render_error(&error))),
        },
        Err(error) => Ok(error_result(crate::error::render_error(&error))),
    }
}

/// `analytics_metadata`: the closed vocabularies, formulas and binding rules this build
/// answers with.
///
/// Every entry is the shared vocabulary's own — `ScopeKind::ALL`, `Mode::ALL` with
/// [`Mode::formula_version`]/[`precision_of`], `Coverage::ALL`, `ProjectionStatus::ALL`,
/// [`AnalyticsDiagnosticKind::ALL`] with its remedy table, the chart document's
/// `FORMAT`/`SCHEMA_VERSION`, and [`declared_units`] — so a seventh diagnostic kind or a
/// third mode appears here the moment the vocabulary grows instead of needing a second
/// list kept in this file. Read-only: the default board is loaded, never saved.
pub(super) fn analytics_metadata(config: &Config, _store: &Store, args: &Value) -> ToolOutcome {
    if let Some(value) = reject_hosted_source(args) {
        return Ok(value);
    }
    let modes: Vec<Value> = Mode::ALL
        .into_iter()
        .map(|mode| {
            json!({
                "mode": mode.as_str(),
                "formula_version": mode.formula_version(),
                "precision": precision_of(mode),
                "unit_required": mode == Mode::Estimate,
                "default": mode == Mode::Count,
            })
        })
        .collect();
    let diagnostics: Vec<Value> = AnalyticsDiagnosticKind::ALL
        .into_iter()
        .map(|kind| json!({ "kind": kind.as_str(), "remedy": kind.default_remedy() }))
        .collect();
    let declared = declared_units(config);
    let structured = json!({
        "scope_kinds": ScopeKind::ALL.map(ScopeKind::as_str),
        "period": {
            "source": "the scope's own planning dates (a cycle's start_date/due_date, a \
                       milestone's target date)",
            "overridable": ["from", "to"],
            "format": "YYYY-MM-DD",
            "max_days": MAX_SERIES_DAYS,
        },
        "modes": modes,
        "units": {
            "rule": "estimate mode needs a unit the project's default board declares a \
                     conversion for; count mode is unitless",
            "declared": declared,
        },
        "projection": {
            "formula_version": PROJECTION_FORMULA_VERSION,
            "statuses": ProjectionStatus::ALL.map(ProjectionStatus::as_str),
            "origin": "as_of (the caller's day, never a clock)",
            "end_date": "the scope's own planning end date, or `to`",
        },
        "coverage": Coverage::ALL.map(Coverage::as_str),
        "snapshot": "a scope bound to a closed snapshot folds that snapshot's period and \
                     provenance; events recorded after its close are not contributions",
        "diagnostics": diagnostics,
        "export": { "format": CHART_FORMAT, "schema_version": CHART_SCHEMA_VERSION },
        "source": "the local record only \u{2014} no hosted analytics service (C-009)",
    });

    // The same facts as a block, for a reader that is not parsing JSON. Strictly derived
    // from the values above, so the two cannot disagree about a vocabulary.
    let mut text = String::new();
    text.push_str("source: the local record only (no hosted analytics service)\n");
    text.push_str(&format!(
        "scope_kinds: {}\n",
        ScopeKind::ALL.map(ScopeKind::as_str).join(", ")
    ));
    text.push_str(&format!(
        "period: the scope's own planning dates, replaceable one bound at a time with \
         from/to; YYYY-MM-DD; at most {MAX_SERIES_DAYS} days\n"
    ));
    for mode in Mode::ALL {
        text.push_str(&format!(
            "mode: {name} formula={formula} precision={precision} unit={unit}{default}\n",
            name = mode.as_str(),
            formula = mode.formula_version(),
            precision = precision_of(mode),
            unit = if mode == Mode::Estimate {
                "required"
            } else {
                "unitless"
            },
            default = if mode == Mode::Count { " default" } else { "" },
        ));
    }
    if declared.is_empty() {
        text.push_str(
            "units: the default board declares none, so estimate mode refuses with \
             missing_conversion\n",
        );
    } else {
        text.push_str(&format!(
            "units: the default board declares: {}\n",
            declared.join(", ")
        ));
    }
    text.push_str(&format!(
        "coverage: {}\n",
        Coverage::ALL.map(Coverage::as_str).join(", ")
    ));
    text.push_str(&format!(
        "projection: formula={PROJECTION_FORMULA_VERSION} statuses: {}; origin as_of; \
         end_date the scope's planning end or to\n",
        ProjectionStatus::ALL
            .map(ProjectionStatus::as_str)
            .join(", ")
    ));
    text.push_str(
        "snapshot: a scope bound to a closed snapshot folds the snapshot's own period and \
         provenance\n",
    );
    text.push_str(&format!(
        "diagnostics: {}\n",
        AnalyticsDiagnosticKind::ALL
            .map(AnalyticsDiagnosticKind::as_str)
            .join(", ")
    ));
    text.push_str(&format!(
        "export: {CHART_FORMAT} schema {CHART_SCHEMA_VERSION}\n"
    ));

    Ok(success_result(text, structured))
}

/// `analytics_projection`: the read-only end-date forecast at `as_of`.
///
/// An unanswerable forecast is an **answer**, not a refusal: the document's own
/// `status` (`insufficient_data`/`unknown`) and its `null` values say so, which is the
/// state the data model requires a surface to expose. The request-level refusals happen
/// before the fold, in the CLI's own words.
pub(super) fn analytics_projection(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    if let Some(value) = reject_hosted_source(args) {
        return Ok(value);
    }
    let request = analytics_args(args)?;
    let as_of = req_string(args, "as_of")?;
    match cmd_analytics::projection_report(config, store, &request, &as_of) {
        Ok(report) => Ok(success_result(
            report.text,
            analytics_document_value(&report.document),
        )),
        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 {
        "analytics_metadata" => Some(analytics_metadata(config, store, arguments)),
        "analytics_burndown" => Some(analytics_burndown(config, store, arguments)),
        "analytics_projection" => Some(analytics_projection(config, store, arguments)),
        "analytics_export" => Some(analytics_export(config, store, arguments)),
        _ => None,
    }
}

/// The one request shape every analytics tool shares: the CLI's own
/// [`AnalyticsScopeArgs`], so the two surfaces parse a scope, period, mode and unit one
/// way rather than through a second argument reader.
///
/// `scope` and `id` are required — a request with nothing to fold is not a request —
/// while the period bounds, mode and unit are optional exactly as the CLI's flags are.
pub(super) fn analytics_args(args: &Value) -> std::result::Result<AnalyticsScopeArgs, ()> {
    Ok(AnalyticsScopeArgs {
        scope: req_string(args, "scope")?,
        id: req_string(args, "id")?,
        from: opt_string(args, "from")?,
        to: opt_string(args, "to")?,
        mode: opt_string(args, "mode")?,
        unit: opt_string(args, "unit")?,
    })
}

/// Refuse an argument that would name a **hosted** analytics source, by name.
///
/// [`call`] checks registry membership only — nothing enforces the
/// `additionalProperties: false` every schema declares — so a call carrying
/// `source: "gitlab"` would otherwise be answered from the local record while the caller
/// believed a remote one had been read. A configured `gitlab`/`linear` target cannot
/// carry a chart document at all (the document says so itself, as an
/// `unsupported_remote_field` finding), so no source *is* ever read here: this surface is
/// local-only by contract (C-009), and a silent drop would misstate which record produced
/// the numbers.
///
/// A targeted check, like `reject_retired_links`, and for the same reason: general
/// unknown-key rejection would change every tool's contract at once, which is not a
/// scope call to make inside a fix for a silent misattribution.
pub(super) fn reject_hosted_source(args: &Value) -> Option<Value> {
    ["source", "target", "remote"].into_iter().find_map(|key| {
        args.as_object()
            .is_some_and(|object| object.contains_key(key))
            .then(|| {
                error_result(format!(
                    "invalid input: analytics reads only the local record, and `{key}` names \
                     a hosted source. There is no analytics service behind `gitlab`/`linear` \
                     \u{2014} those names are remote *export mappings*, not chart sources, and \
                     a configured target that cannot carry a chart document is reported by \
                     the document's own `unsupported_remote_field` finding. Name the local \
                     scope with `scope` and `id` instead"
                ))
            })
    })
}

/// The shared chart document as a tool's structured answer.
///
/// The document itself through `serde_json`: the one typed shape C-008 mandates, so the
/// MCP answer and the export's own bytes are one reading of the repository rather than
/// two shapes kept in step. An `Option` the record could not answer serializes as `null`
/// — never a `0` — and a coverage marker or a projection status as its stable spelling.
pub(super) fn analytics_document_value(document: &ChartExport) -> Value {
    serde_json::to_value(document).expect("chart document serialization is infallible")
}