liminal-cli 0.3.1

The liminal command: serve the network, maintain its stores, or serve its discovery MCP over stdio
Documentation
//! The ticket carries a held answer without interpreting its words.

use std::num::NonZeroUsize;

use serde_json::{Value, json};

use super::tools::ToolResult;

/// A held result whose text is never included in diagnostics.
pub struct HeldWords<'a> {
    draft: &'a str,
    words: &'a str,
}

impl std::fmt::Debug for HeldWords<'_> {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter.debug_struct("HeldWords").finish_non_exhaustive()
    }
}

/// A malformed held result; the rejected bytes are never retained here.
#[derive(Debug, thiserror::Error)]
#[error("held_result_invalid")]
pub struct HeldResultInvalid;

impl<'a> HeldWords<'a> {
    /// Checks the server's held answer before carrying it to the harness.
    /// `identifier_bytes` bounds routing metadata; `words_bytes` bounds one
    /// tool response without truncating what the approver saw.
    ///
    /// # Errors
    /// Refuses an absent or controlled id, empty words, or either byte bound.
    pub fn new(
        draft: &'a str,
        words: &'a str,
        identifier_bytes: NonZeroUsize,
        words_bytes: NonZeroUsize,
    ) -> Result<Self, HeldResultInvalid> {
        if draft.is_empty()
            || draft.len() > identifier_bytes.get()
            || draft.chars().any(char::is_control)
            || words.is_empty()
            || words.len() > words_bytes.get()
        {
            return Err(HeldResultInvalid);
        }
        Ok(Self { draft, words })
    }

    /// The successful structured answer; authority remains with the server.
    #[must_use]
    pub fn tool_result(&self) -> ToolResult {
        ToolResult::ok(json!({"draft":self.draft, "words":self.words}))
    }
}

/// Supplies the text of an already successful held result to MCP shaping.
/// A failed result stays a failure, even if it also carries a draft id.
#[must_use]
pub fn text(result: &ToolResult) -> Option<String> {
    if result.is_error {
        return None;
    }
    let object = result.structured.as_object()?;
    if object.len() != 2 {
        return None;
    }
    let draft = object.get("draft")?.as_str()?;
    let words = object.get("words")?.as_str()?;
    if draft.is_empty() || draft.chars().any(char::is_control) || words.is_empty() {
        return None;
    }
    Some(format!("held by draft {draft}: {words}"))
}

/// Recognises a wire answer without converting an ordinary refusal to held.
///
/// # Errors
/// A held answer carries malformed or oversized fields.
pub fn from_wire(
    answer: &Value,
    identifier_bytes: NonZeroUsize,
    words_bytes: NonZeroUsize,
) -> Result<Option<ToolResult>, HeldResultInvalid> {
    let paired = answer.as_object().is_some_and(|object| {
        object.len() == 2 && object.contains_key("draft") && object.contains_key("words")
    });
    if answer.get("state").and_then(Value::as_str) != Some("held") && !paired {
        return Ok(None);
    }
    if answer.get("refusal").is_some()
        || answer.get("error").is_some()
        || answer
            .get("isError")
            .is_some_and(|value| value.as_bool() != Some(false))
        || answer
            .get("is_error")
            .is_some_and(|value| value.as_bool() != Some(false))
    {
        return Err(HeldResultInvalid);
    }
    let draft = answer
        .get("draft")
        .and_then(Value::as_str)
        .ok_or(HeldResultInvalid)?;
    let words = answer
        .get("words")
        .and_then(Value::as_str)
        .ok_or(HeldResultInvalid)?;
    Ok(Some(
        HeldWords::new(draft, words, identifier_bytes, words_bytes)?.tool_result(),
    ))
}

impl std::fmt::Debug for ToolResult {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_struct("ToolResult")
            .field("is_error", &self.is_error)
            .finish_non_exhaustive()
    }
}

#[cfg(test)]
#[path = "held_tests.rs"]
mod tests;