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

/// Required string argument. Absent + `null` โ†’ protocol error.
pub(super) fn req_string(args: &Value, key: &str) -> std::result::Result<String, ()> {
    let value = args.get(key).ok_or(())?;
    value.as_str().map(str::to_string).ok_or(())
}

/// Optional string argument. `null` counts as absent (lenient), any other
/// non-string type is a strict `-32602` protocol error.
pub(super) fn opt_string(args: &Value, key: &str) -> std::result::Result<Option<String>, ()> {
    let Some(value) = args.get(key) else {
        return Ok(None);
    };
    if value.is_null() {
        return Ok(None);
    }
    value.as_str().map(|s| Some(s.to_string())).ok_or(())
}

/// Optional issue selector, accepting a number or a string.
///
/// **Not** [`opt_string`], which is for free text. The schemas for the
/// selector-taking relationship tools declare `{"type": ["string", "integer"]}`
/// and `docs/mcp.md` says so in prose, so an agent following the documented
/// schema and sending `{"issue": 0}` got a JSON-RPC `-32602` while the CLI
/// accepted the same thing โ€” and the other selector-taking tools
/// (`issue_chain`, `issue_blocked_state`) went through [`selector`] and were
/// fine. Two tools, two rules for the same argument, on the same surface.
pub(super) fn opt_selector(args: &Value, key: &str) -> std::result::Result<Option<String>, ()> {
    let Some(value) = args.get(key) else {
        return Ok(None);
    };
    match value {
        Value::Null => Ok(None),
        Value::String(s) => Ok(Some(s.clone())),
        Value::Number(n) => n.as_i64().map(|i| Some(i.to_string())).ok_or(()),
        _ => Err(()),
    }
}

/// Reject the retired `links` argument loudly, naming the typed replacements.
///
/// WP05 removed this argument because the typed model replaced it, and that
/// removal is the user-visible half of C-001. But `tools::call` only checks
/// registry membership โ€” nothing enforces the `additionalProperties: false` every
/// schema declares โ€” so an integration that used to pass `links` got a
/// **success** response and silently lost the relationships, while
/// `tix create --links 0` failed loudly as an unknown flag.
///
/// A silent drop is the worst of the three available behaviours: the caller
/// believes the relationships were stored, `tix link list` disagrees, and nothing
/// in between says why. Rejecting turns a data-loss bug into a message.
///
/// A targeted check, not general unknown-key rejection. General rejection is the
/// better design and is a separate change โ€” it would alter the contract of every
/// tool at once, which is a scope call and not one to make inside a fix for a
/// silent data loss.
pub(super) fn reject_retired_links(args: &Value) -> Option<Value> {
    if !args.as_object().is_some_and(|o| o.contains_key("links")) {
        return None;
    }
    Some(error_result(
        "invalid input: `links` was removed \u{2014} the typed model replaces it. \
         create each relationship with `link_issue` (`a`, `b`, `kind`) and remove \
         an existing one with `remove_relationship` (`id`). The old argument \
         *replaced* the whole set, so replacing it means one `remove_relationship` \
         per existing edge plus one `link_issue` per new one"
            .to_string(),
    ))
}

/// Optional array-of-strings argument.
///
/// For an argument that stands for a **repeatable CLI flag**: one array entry is
/// one occurrence, taken verbatim. That is why it is not [`opt_string`] plus
/// [`crate::commands::parse_csv_list`]: the CLI's `--add-project` / `--remove-project`
/// hand `plan_edit_associations` each occurrence exactly as typed, so `" p-a "`
/// and `""` are *references that do not resolve* on the CLI. Trimming or
/// comma-splitting them here would accept a call the CLI refuses, which is a
/// parity defect, not a convenience.
///
/// `null` counts as absent (lenient, like [`opt_string`]); a present non-array or
/// an entry that is not a string is a strict `-32602` protocol error.
pub(super) fn opt_string_array(
    args: &Value,
    key: &str,
) -> std::result::Result<Option<Vec<String>>, ()> {
    let Some(value) = args.get(key) else {
        return Ok(None);
    };
    if value.is_null() {
        return Ok(None);
    }
    let items = value.as_array().ok_or(())?;
    let mut out: Vec<String> = Vec::with_capacity(items.len());
    for item in items {
        out.push(item.as_str().ok_or(())?.to_string());
    }
    Ok(Some(out))
}

/// Optional boolean argument with the same strictness as [`opt_string`].
pub(super) fn opt_bool(args: &Value, key: &str) -> std::result::Result<Option<bool>, ()> {
    let Some(value) = args.get(key) else {
        return Ok(None);
    };
    if value.is_null() {
        return Ok(None);
    }
    value.as_bool().map(Some).ok_or(())
}

/// Issue selector: accepts an integer (`#123`) or a string fragment, matching
/// `parse_selector` precedence (`contracts/mcp-tools.md` ยง2).
pub(super) fn selector(args: &Value, key: &str) -> std::result::Result<String, ()> {
    let value = args.get(key).ok_or(())?;
    match value {
        Value::Null => Err(()),
        Value::String(s) => Ok(s.clone()),
        Value::Number(n) => n.as_i64().map(|i| i.to_string()).ok_or(()),
        _ => Err(()),
    }
}

// ---------------------------------------------------------------------------
// T021 โ€“ structured results + domain-error mapping
// ---------------------------------------------------------------------------

pub(super) fn req_u32(args: &Value, key: &str) -> std::result::Result<u32, ()> {
    let value = args.get(key).ok_or(())?;
    let number = value.as_u64().ok_or(())?;
    u32::try_from(number).map_err(|_| ())
}

pub(super) fn opt_u32(args: &Value, key: &str) -> std::result::Result<Option<u32>, ()> {
    let Some(value) = args.get(key) else {
        return Ok(None);
    };
    if value.is_null() {
        return Ok(None);
    }
    value
        .as_u64()
        .and_then(|number| u32::try_from(number).ok())
        .map(Some)
        .ok_or(())
}

pub(super) fn req_i64(args: &Value, key: &str) -> std::result::Result<i64, ()> {
    args.get(key).and_then(Value::as_i64).ok_or(())
}

pub(super) fn opt_i64(args: &Value, key: &str) -> std::result::Result<Option<i64>, ()> {
    let Some(value) = args.get(key) else {
        return Ok(None);
    };
    if value.is_null() {
        return Ok(None);
    }
    value.as_i64().map(Some).ok_or(())
}