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

/// The project's `tix.toml` and the path it came from, loaded for a template
/// operation.
///
/// A management mutation replaces the whole `Config` and saves it atomically, so
/// it needs a **mutable** config and the path it came from — neither of which the
/// server holds (it is handed a `&Config`). `commands::templates::run` loads the
/// same pair for the same reason, and `Config::project_root` performs the same
/// upward discovery as the bootstrap, so MCP manages the file the rest of the
/// session reads. Loading per call is what makes a template another surface just
/// wrote visible to the next call.
pub(super) fn template_management_config() -> TixResult<(Config, std::path::PathBuf)> {
    let cwd = std::env::current_dir().map_err(crate::error::TixError::Io)?;
    let root = Config::project_root(&cwd).ok_or_else(|| {
        crate::error::TixError::Config("no tix.toml found — run \"tix init\" first".into())
    })?;
    let path = root.join("tix.toml");
    let config = Config::load(&root)?;
    Ok((config, path))
}

/// The stored record as JSON: stable ID, display label, explicit scope,
/// defaults, prompts, references, and preserved unknown metadata.
///
/// Every part is data the registry already owns; this projects it, so one
/// template renders identically through `list_templates`, `get_template`, and
/// the outcome of a mutation.
pub(super) fn template_record_value(id: &str, template: &Template) -> Value {
    json!({
        "id": id,
        "name": template.display_name(id),
        "scope": template.scope.to_string(),
        "defaults": template.defaults,
        "prompts": template.prompts,
        "references": template.references,
        "extensions": template.extensions,
    })
}

/// The record of one stored template as JSON.
pub(super) fn template_value(detail: &TemplateDetail) -> Value {
    template_record_value(detail.id(), detail.template())
}

/// The stored record for `id`, or `null` when the registry does not hold it.
///
/// A create/update always leaves the record present, so `None` here would mean
/// the service's own invariant broke; it is reported as `null` rather than
/// substituted with a guess.
pub(super) fn template_registry_value(config: &Config, id: &str) -> Option<Value> {
    config
        .issue_templates
        .get(id)
        .map(|template| template_record_value(id, template))
}

/// A template operation's `structuredContent`.
///
/// `status` is the shared vocabulary the batch tools use — `accepted`, `no-op`,
/// `rejected`, `failed` — so a client branches on the word rather than on prose.
pub(super) fn template_outcome_value(
    mutation: &str,
    status: &str,
    template: Option<Value>,
    diagnostics: Vec<Value>,
) -> Value {
    json!({
        "status": status,
        "mutation": mutation,
        "template": template,
        "diagnostics": diagnostics,
    })
}

/// An `isError: true` result that still carries the structured outcome.
///
/// Every other tool reports a refusal as text only. A template refusal is data
/// the caller must act on — which field, which template, which of
/// rejected/failed — so it is structured as well as worded, and a client does not
/// have to parse the sentence to find it.
pub(super) fn template_error_result(text: String, structured: Value) -> Value {
    json!({
        "content": [{ "type": "text", "text": text }],
        "structuredContent": structured,
        "isError": true,
    })
}

/// A refused template request: a structured field diagnostic plus matching text,
/// in the same `status`/`diagnostics` shape a service refusal uses.
pub(super) fn template_reject(mutation: &str, field: &str, message: String) -> ToolOutcome {
    let text = format!("invalid input: `{field}`: {message}");
    let structured = template_outcome_value(
        mutation,
        "rejected",
        None,
        vec![json!({
            "kind": "invalid-payload",
            "field": field,
            "message": message,
        })],
    );
    Ok(template_error_result(text, structured))
}

/// The service's refusal as a result: the CLI's own wording, plus the field the
/// refusal named and whether it was a validation rejection or an I/O failure.
pub(super) fn template_write_refusal(mutation: &str, error: &TemplateWriteError) -> Value {
    let status = if error.is_refusal() {
        "rejected"
    } else {
        "failed"
    };
    let message = crate::error::render_error(error.inner());
    let structured = template_outcome_value(
        mutation,
        status,
        None,
        vec![json!({
            "kind": status,
            "field": refusal_field(&message),
            "message": message.clone(),
        })],
    );
    template_error_result(message, structured)
}

/// The field a service refusal names: the first backticked span of the rendered
/// error that is an `issue_templates.<id>.<field>` key (the registry's
/// field-aware validation errors), else the registry root.
///
/// The span is *found*, not assumed to be first, because the registry's `Config`
/// errors carry the CLI's own text in front of the key
/// (`config error: \`issue_templates.<id>.<field>\` …`).
pub(super) fn refusal_field(message: &str) -> &str {
    message
        .split('`')
        .skip(1)
        .step_by(2)
        .find(|span| span.starts_with("issue_templates"))
        .unwrap_or("issue_templates")
}

/// A `create_template`/`update_template` request as the service's own
/// [`TemplateDefinition`].
///
/// MCP parses **no** template grammar: `defaults`, `prompts` and `references`
/// deserialize into the registry's own typed structs — each with its own
/// `deny_unknown_fields` — and the combination of a supplied name/scope mirrors
/// `commands::templates`'s definition builders. An absent block is not supplied,
/// which is what makes a rename a rename on the update path.
///
/// A JSON type error (a number where an object belongs) is a protocol `-32602`;
/// a well-typed payload the schema cannot accept is a **rejected** outcome with a
/// field-aware diagnostic, never a silent drop.
pub(super) fn template_definition(
    args: &Value,
    mutation: &str,
) -> ToolOutcome2<TemplateDefinition> {
    let name = opt_string_step(args, "name")?;
    let scope = match opt_string_step(args, "scope")? {
        Some(raw) => match raw.parse::<TemplateScope>() {
            Ok(scope) => Some(scope),
            Err(message) => return Err(template_reject(mutation, "scope", message)),
        },
        None => None,
    };
    let defaults = template_block::<TemplateDefaults>(args, "defaults", mutation)?;
    let prompts = template_items::<TemplatePrompt>(args, "prompts", mutation)?;
    let references = template_items::<TemplateReference>(args, "references", mutation)?;

    // The same combination the CLI adapter performs: a supplied field replaces
    // the whole block, an absent one is left alone (`create` fills its own
    // defaults inside the service; `update` leaves the stored value untouched).
    let mut definition = match (name, scope) {
        (Some(name), Some(scope)) => TemplateDefinition::new(name, scope),
        (Some(name), None) => TemplateDefinition::rename(name),
        (None, Some(scope)) => TemplateDefinition::unnamed(scope),
        (None, None) => TemplateDefinition::default(),
    };
    if let Some(defaults) = defaults {
        definition = definition.with_defaults(defaults);
    }
    if let Some(prompts) = prompts {
        definition = definition.with_prompts(prompts);
    }
    if let Some(references) = references {
        definition = definition.with_references(references);
    }
    Ok(definition)
}

/// [`opt_string`] lifted into the inner-step error type: a JSON type error is a
/// protocol `-32602`, which on this side of the handler is `Err(proto())`.
pub(super) fn opt_string_step(args: &Value, key: &str) -> ToolOutcome2<Option<String>> {
    opt_string(args, key).map_err(|()| proto())
}

/// One optional typed block (`defaults`) deserialized from the request.
pub(super) fn template_block<T: serde::de::DeserializeOwned>(
    args: &Value,
    key: &str,
    mutation: &str,
) -> ToolOutcome2<Option<T>> {
    match args.get(key) {
        None | Some(Value::Null) => Ok(None),
        Some(value) => match serde_json::from_value::<T>(value.clone()) {
            Ok(block) => Ok(Some(block)),
            Err(error) => Err(template_reject(mutation, key, error.to_string())),
        },
    }
}

/// One optional typed list (`prompts`, `references`), each item deserialized
/// separately so a bad item is diagnosed with its index.
pub(super) fn template_items<T: serde::de::DeserializeOwned>(
    args: &Value,
    key: &str,
    mutation: &str,
) -> ToolOutcome2<Option<Vec<T>>> {
    let Some(value) = args.get(key) else {
        return Ok(None);
    };
    if value.is_null() {
        return Ok(None);
    }
    let Some(items) = value.as_array() else {
        // A well-formed request naming the wrong JSON type is the same protocol
        // error `opt_string` gives a number where a string belongs.
        return Err(proto());
    };
    let mut parsed = Vec::with_capacity(items.len());
    for (index, item) in items.iter().enumerate() {
        match serde_json::from_value::<T>(item.clone()) {
            Ok(value) => parsed.push(value),
            Err(error) => {
                return Err(template_reject(
                    mutation,
                    &format!("{key}[{index}]"),
                    error.to_string(),
                ));
            }
        }
    }
    Ok(Some(parsed))
}

// ---------------------------------------------------------------------------
// text mirrors (byte-identical to the CLI's stdout rendering, NFR-005)
// ---------------------------------------------------------------------------

/// `tix list` table text.
pub(super) fn list_text(
    issues: &[Issue],
    group: Option<&str>,
    resolution: &crate::workflow::CategoryResolution,
) -> String {
    list::render_default_table(issues, group, resolution, 0)
}

pub(super) const MAX_LINE_WIDTH: usize = 200;

/// Truncate a matched context line (same as `search::run`).
pub(super) fn clipped(line: &str) -> String {
    if line.chars().count() <= MAX_LINE_WIDTH {
        return line.to_string();
    }
    let head: String = line.chars().take(MAX_LINE_WIDTH).collect();
    format!("{head}…")
}

/// `tix search` result text (empty query yields `No issues found.`).
pub(super) fn search_text(hits: &[SearchHit]) -> String {
    if hits.is_empty() {
        return "No issues found.\n".to_string();
    }
    let mut out = String::new();
    for hit in hits {
        out.push_str(&format!(
            "#{} {} — {} ({})\n",
            hit.issue.number, hit.issue.title, hit.issue.status, hit.issue.filename
        ));
        for line in &hit.matched_lines {
            out.push_str(&format!("  {}\n", clipped(line)));
        }
    }
    out
}

pub(super) fn sort_issues(issues: &mut [Issue], sort: Option<&str>) {
    match sort.unwrap_or("created") {
        "due" => issues.sort_by(|a, b| a.due_date.cmp(&b.due_date).then(a.number.cmp(&b.number))),
        "start" => issues.sort_by(|a, b| {
            a.start_date
                .cmp(&b.start_date)
                .then(a.number.cmp(&b.number))
        }),
        "reviewer" => {
            issues.sort_by(|a, b| a.reviewer.cmp(&b.reviewer).then(a.number.cmp(&b.number)))
        }
        "cycle" => issues.sort_by(|a, b| {
            a.cycles
                .as_ref()
                .and_then(|v| v.first())
                .cmp(&b.cycles.as_ref().and_then(|v| v.first()))
                .then(a.number.cmp(&b.number))
        }),
        "milestone" => issues.sort_by(|a, b| {
            a.milestones
                .as_ref()
                .and_then(|v| v.first())
                .cmp(&b.milestones.as_ref().and_then(|v| v.first()))
                .then(a.number.cmp(&b.number))
        }),
        "status" => issues.sort_by(|a, b| {
            a.status
                .display()
                .cmp(b.status.display())
                .then(a.number.cmp(&b.number))
        }),
        "priority" => issues.sort_by(|a, b| {
            a.priority
                .to_string()
                .cmp(&b.priority.to_string())
                .then(a.number.cmp(&b.number))
        }),
        _ => issues.sort_by(|a, b| a.created.cmp(&b.created).then(a.number.cmp(&b.number))),
    }
}

/// `tix lint` text: the CLI's clean line, or a count line plus one
/// `file:line:col kind value message` line per finding
/// (`contracts/mcp-tools.md` §3.12).
pub(super) fn lint_text(n_issues: usize, findings: &[lint::TixIssueFinding]) -> String {
    // WP06: the summary line now follows the **CLI's** rule, not "any finding at
    // all". `mcp-tools.md` §4 says tool text "mirrors the CLI's stdout rendering …
    // so a human reading tool text sees identical facts", and before this change the
    // two disagreed: the CLI said `Lint OK with 3 warnings` for a board relying on
    // the safe default, while this said `Checked 1 issues; 3 finding(s)` — the same
    // store, two different verdicts, one of which implied a problem.
    //
    // It is only visible now because WP06's `unmapped_column_default` is a
    // `Severity::Warning` and is *reported*. Before that, `findings.is_empty()` and
    // "no errors" were the same predicate on every real store, so the divergence had
    // nothing to show itself on.
    let errors = findings
        .iter()
        .filter(|f| f.severity == lint::Severity::Error)
        .count();
    if errors == 0 {
        let warnings = findings.len();
        return if warnings == 0 {
            format!("Lint OK: {n_issues} issues checked.\n")
        } else {
            format!(
                "Lint OK with {warnings} warning{}: {n_issues} issues checked.\n",
                if warnings == 1 { "" } else { "s" }
            )
        };
    }
    // Errors: one line per finding, in the format §3.12 pins. No count line, because
    // the CLI does not print one in this case either — the diagnostics speak for
    // themselves and a count above them is a second thing to keep in step.
    let mut out = String::new();
    for f in findings
        .iter()
        .filter(|f| f.severity == lint::Severity::Error)
    {
        out.push_str(&format!(
            "{}:{}:{} {} {} {}\n",
            f.file, f.line, f.column, f.kind, f.value, f.message
        ));
    }
    out
}

/// Resolve an issue's filename for a selector (used to report the deleted
/// path before it is gone).
pub(super) fn resolve_filename(store: &Store, raw: &str) -> TixResult<String> {
    let selector = parse_selector(store, raw)?;
    let issue = store.get(selector)?;
    Ok(issue.filename)
}

// ---------------------------------------------------------------------------
// dispatch
// ---------------------------------------------------------------------------