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

/// A `kind` argument that must be present, reporting the diagnostic the CLI
/// would produce for the same mistake.
///
/// Three distinct failures, deliberately not collapsed:
///
/// 1. **absent** — the argument is missing, so the message names it and the four
///    accepted values. A protocol `-32602` would say only "invalid params",
///    which tells an agent nothing about the closed vocabulary it must choose
///    from, and the vocabulary *is* the contract.
/// 2. **wrong JSON type** — a type error is a protocol concern, reported as one.
/// 3. **an unknown value** — the service's own text, verbatim. This case was
///    being folded into case 1, which replaced the authoritative diagnostic with
///    a generic "must be one of" and lost the `unknown_kind` kind the CLI
///    reports — the exact drift `surface-parity.md` §2 forbids, introduced here
///    and caught by `t021_an_unknown_kind_rejects_with_the_clis_exact_text`.
///
/// The *field* in the missing case is spelled for the argument the caller used
/// (`kind`, not the CLI's `--type`); the diagnostic kind and field are otherwise
/// identical on both surfaces.
pub(super) fn require_relation_kind(
    args: &Value,
    key: &str,
) -> std::result::Result<crate::relationships::RelationKind, Value> {
    let advice = format!("pass `{key}` blocks|blocked_by|duplicates|related");
    // **Absent and `null` are the same case.** An agent that sends `{"kind": null}`
    // means "not provided", and it must get the "relation type required"
    // diagnostic — not "must be a string", which describes a type error the caller
    // did not make. An earlier version of this function split them and a `null`
    // got the worse message.
    let Some(value) = args.get(key).filter(|v| !v.is_null()) else {
        return Err(error_result(
            crate::relationships::Diagnostic::unknown_kind(
                "kind",
                format!("relation type required: {advice}"),
            )
            .to_string(),
        ));
    };
    // A non-string is a **protocol** error, not a domain one: the schema says
    // string, and there is no domain reading of `kind: 7`.
    let Some(raw) = value.as_str() else {
        return Err(error_result(format!(
            "invalid input: `{key}` must be a string naming one of blocks, blocked_by, duplicates, related"
        )));
    };
    // The **whole** CLI rule, not just the parser: `parse_kind` on its own left the
    // trim and the blank case to this function, so `" blocks "` was accepted by
    // `tix link --type " blocks "` and rejected here.
    //
    // The advice is this surface's own, so the *decision* is shared while each
    // surface names its own argument — §2 asks for "the same named field/selector",
    // not for the CLI's flag spelling repeated to an agent.
    crate::commands::link::require_kind_named(Some(raw), &advice)
        .map_err(|e| error_result(crate::error::render_error(&e)))
}

/// The structured payload for one relationship.
///
/// Carries the stable id, the canonical kind, both endpoints, and the display
/// name, so an agent can act on the result with `edit_relationship` /
/// `remove_relationship` without a second round trip to recover the id.
pub(super) fn relationship_value(rel: &crate::relationships::Relationship) -> Value {
    json!({
        "id": rel.id,
        "from": rel.from,
        "to": rel.to,
        "kind": rel.kind.as_str(),
        "display": rel.display,
    })
}

/// Resolve a selector argument to an issue number, MCP-side.
///
/// The `Store` does the resolving, through the same `parse_selector` grammar the
/// CLI uses, so an ambiguous selector fails identically on both surfaces.
pub(super) fn resolve_number(store: &Store, raw: &str) -> std::result::Result<u32, ()> {
    store
        .get(crate::commands::parse_selector(store, raw).map_err(|_| ())?)
        .map(|i| i.number)
        .map_err(|_| ())
}

/// Run `f` with the relationship graph over the whole store.
///
/// A closure rather than a returned `(Vec<Issue>, RelationshipGraph)` pair
/// because the graph **borrows** the issue set: the two cannot be returned
/// together. Keeping the construction here means the read tools get one graph
/// build and one read pass, and a derived read can never be computed from a
/// different snapshot than the one beside it.
///
/// The closure returns an `isError` result for a domain failure, so a
/// relationship read reports a problem rather than panicking.
pub(super) fn with_graph<T, F>(store: &Store, f: F) -> std::result::Result<T, Value>
where
    F: FnOnce(&crate::relationships::RelationshipGraph<'_>) -> std::result::Result<T, Value>,
{
    let (issues, done_column) = store
        .graph_source()
        .map_err(|e| error_result(crate::error::render_error(&e)))?;
    let graph = crate::commands::graph_over(&issues, &done_column);
    f(&graph)
}

/// Turn a failed selector resolution into the repository's own message.
///
/// The CLI's `parse_selector` distinguishes "no match" from "ambiguous", and MCP
/// must not collapse the two -- an ambiguous selector reported as not-found
/// sends an agent looking in the wrong place. Reusing the error string keeps the
/// two surfaces saying the same thing about the same selector.
pub(super) fn with_number_resolution(
    store: &Store,
    raw: &str,
    not_found: impl FnOnce() -> Value,
) -> Value {
    match crate::commands::parse_selector(store, raw) {
        Ok(_) => not_found(),
        Err(e) => error_result(crate::error::render_error(&e)),
    }
}

// ---------------------------------------------------------------------------
// WP07 / T029 — the hierarchy read tools
// ---------------------------------------------------------------------------
//
// Three derived reads, matching the three rows of the parity contract's
// operation table that are reads. Every handler goes through
// `commands::parent::source`, the same snapshot the CLI reads, and returns the
// CLI's own rendered text alongside structured values.
//
// Both, deliberately. The contract requires the same accept/reject decision, the
// same *value* (§2.5 — "not merely the same rendered text"), and the same
// derived read. A text-only tool satisfies the first and third; a numbers-only
// tool fails the "same text" half that every other MCP tool in this file keeps by
// returning the CLI's wording as its content. So the text is byte-identical and
// the numbers are present to be compared without parsing prose.

/// One snapshot of the hierarchy, plus the board's completion column.
pub(super) struct HierarchySnapshot {
    pub(super) index: crate::hierarchy::HierarchyIndex,
    pub(super) issues: Vec<Issue>,
    pub(super) done_column: String,
}

pub(super) fn hierarchy_snapshot(store: &Store) -> std::result::Result<HierarchySnapshot, ()> {
    let (index, issues, done_column) = crate::commands::parent::source(store).map_err(|_| ())?;
    Ok(HierarchySnapshot {
        index,
        issues,
        done_column,
    })
}

/// Resolve the required `issue` argument to a number, through the shared selector
/// parser, so a number, a filename and a title fragment all work here exactly as
/// they do on the CLI.
pub(super) fn required_number(store: &Store, args: &Value) -> std::result::Result<u32, ()> {
    let raw = req_string(args, "issue")?;
    resolve_number(store, &raw)
}

/// The same "not in the current snapshot" refusal the CLI gives, as a tool error.
///
/// Deliberately the shared wording rather than a tool-specific one: a caller
/// comparing the two surfaces must not be able to tell them apart by a missing
/// issue.
pub(super) fn missing_issue(number: u32) -> Value {
    error_result(
        crate::error::TixError::NotFound(format!("issue #{number} is not in the current snapshot"))
            .to_string(),
    )
}

// ---------------------------------------------------------------------------
// WP06 / T028–T029 — the template registry surface
// ---------------------------------------------------------------------------