mant 0.8.0

Local-first TUI, structured CLI, and MCP server for manuals and Markdown
Documentation
//! Closed, compact input schemas exposed by the agent-facing MCP tools.

use mant_protocol::{
    CatalogDocumentKind, CatalogQuery, DocumentScope, DocumentSelector, DocumentTraversal,
    NodeSelector, OutlineDetail, QueryInput, QueryRequest, QueryView, SearchCase, SearchSyntax,
};
use schemars::JsonSchema;
use serde::Deserialize;

/// Maximum accepted logical document selector length.
pub(super) const MAX_DOCUMENT_BYTES: usize = mant_protocol::MAX_DOCUMENT_SELECTOR_BYTES;
/// Maximum accepted continuation token length.
pub(super) const MAX_CURSOR_BYTES: usize = 256;
/// Maximum selectors accepted by one focused read.
pub(super) const MAX_SELECTORS: usize = 16;
/// Fixed catalog page size for agent discovery.
pub(super) const FIND_PAGE_SIZE: u32 = 50;
/// Default match-line page size for in-document search.
pub(super) const DEFAULT_SEARCH_PAGE_SIZE: u32 = 20;
/// Maximum match-line page size exposed to an MCP client.
pub(super) const MAX_SEARCH_PAGE_SIZE: u32 = 100;
pub(super) const MAX_FIND_QUERY_BYTES: usize = 1024;
const MAX_SOURCE_BYTES: usize = 128;
pub(super) const MAX_MANUAL_SECTION_BYTES: usize = 32;
const MAX_SELECTOR_BYTES: usize = 512;
const MAX_PATTERN_BYTES: usize = 4096;

/// Discover logical document identities in the local catalog.
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct FindParams {
    /// Optional case-insensitive literal, bounded to 1024 UTF-8 bytes at runtime.
    pub(super) query: Option<String>,
    /// Restrict results to registered Markdown or native manuals.
    pub(super) kind: Option<CatalogDocumentKind>,
    /// Restrict Markdown results to one configured source.
    #[schemars(length(min = 1))]
    pub(super) source: Option<String>,
    /// Restrict native manuals to one exact manual section.
    #[schemars(length(min = 1))]
    pub(super) manual_section: Option<String>,
    /// Opaque continuation token returned by an earlier identical call.
    #[schemars(length(min = 1))]
    pub(super) cursor: Option<String>,
}

/// Parameters shared by focused document tools.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct OutlineParams {
    /// Unqualified name or canonical catalog path returned by `mant_find`.
    #[schemars(length(min = 1))]
    pub(super) document: String,
    /// Include sections only (the default), or semantic entries as well.
    pub(super) detail: Option<OutlineDetail>,
    /// Opaque continuation token returned by an earlier identical call.
    #[schemars(length(min = 1))]
    pub(super) cursor: Option<String>,
}

/// Retrieve one or more nodes selected from a document outline.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct ReadParams {
    /// Unqualified name or canonical catalog path returned by `mant_find`.
    #[schemars(length(min = 1))]
    pub(super) document: String,
    /// Outline paths, stable IDs, or semantic aliases.
    #[schemars(length(min = 1, max = 16))]
    pub(super) selectors: Vec<NodeSelector>,
    /// Opaque continuation token returned by an earlier identical call.
    #[schemars(length(min = 1))]
    pub(super) cursor: Option<String>,
}

/// Resolve one semantic command, option, variable, or environment entry.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct ExplainParams {
    /// One or more unqualified names or canonical IDs returned by `mant_find`.
    #[schemars(length(min = 1, max = 16))]
    pub(super) documents: Vec<String>,
    /// Follow typed links from the initial documents.
    #[serde(default)]
    pub(super) follow_links: bool,
    /// Maximum followed-link distance; valid only with `followLinks`.
    #[schemars(range(max = 32))]
    pub(super) max_depth: Option<u16>,
    /// Maximum distinct documents including roots; valid only with `followLinks`.
    #[schemars(range(min = 1, max = 256))]
    pub(super) max_documents: Option<u32>,
    /// Exact alias, outline path, or stable ID of the entry.
    #[schemars(length(min = 1))]
    pub(super) entry: String,
    /// Opaque continuation token returned by an earlier identical call.
    #[schemars(length(min = 1))]
    pub(super) cursor: Option<String>,
}

/// Search visible document text with bounded result pages.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct SearchParams {
    /// One or more unqualified names or canonical IDs returned by `mant_find`.
    #[schemars(length(min = 1, max = 16))]
    pub(super) documents: Vec<String>,
    /// Follow typed links from the initial documents.
    #[serde(default)]
    pub(super) follow_links: bool,
    /// Maximum followed-link distance; valid only with `followLinks`.
    #[schemars(range(max = 32))]
    pub(super) max_depth: Option<u16>,
    /// Maximum distinct documents including roots; valid only with `followLinks`.
    #[schemars(range(min = 1, max = 256))]
    pub(super) max_documents: Option<u32>,
    /// Literal text or a regular expression, depending on `syntax`.
    #[schemars(length(min = 1))]
    pub(super) pattern: String,
    /// Interpret `pattern` literally (the default) or as a regular expression.
    pub(super) syntax: Option<SearchSyntax>,
    /// Case-folding policy. The default is `insensitive`.
    pub(super) case: Option<SearchCase>,
    /// Restrict matches to Unicode-aware word boundaries.
    #[serde(default)]
    pub(super) word: bool,
    /// Visible lines of context before and after a match, from zero through five.
    #[serde(default)]
    #[schemars(range(max = 5))]
    pub(super) context_lines: u16,
    /// Maximum matching line groups returned before a continuation cursor.
    #[schemars(range(min = 1, max = 100))]
    pub(super) limit: Option<u32>,
    /// Opaque continuation token returned by an earlier identical call.
    #[schemars(length(min = 1))]
    pub(super) cursor: Option<String>,
}

pub(super) struct ValidatedFindParams {
    pub(super) query: Option<String>,
    pub(super) kind: Option<CatalogDocumentKind>,
    pub(super) source: Option<String>,
    pub(super) manual_section: Option<String>,
    pub(super) cursor: Option<String>,
}

pub(super) struct ValidatedOutlineParams {
    pub(super) document: String,
    pub(super) detail: OutlineDetail,
    pub(super) cursor: Option<String>,
}

pub(super) struct ValidatedReadParams {
    pub(super) document: String,
    pub(super) selectors: Vec<NodeSelector>,
    pub(super) cursor: Option<String>,
}

pub(super) struct ValidatedExplainParams {
    pub(super) scope: DocumentScope,
    pub(super) entry: String,
    pub(super) cursor: Option<String>,
}

pub(super) struct ValidatedSearchParams {
    pub(super) scope: DocumentScope,
    pub(super) pattern: String,
    pub(super) syntax: SearchSyntax,
    pub(super) case: SearchCase,
    pub(super) word: bool,
    pub(super) context_lines: u16,
    pub(super) limit: u32,
    pub(super) cursor: Option<String>,
}

impl FindParams {
    pub(super) fn validate(self) -> Result<ValidatedFindParams, String> {
        let query = self
            .query
            .filter(|query| !query.trim().is_empty())
            .map(|query| bounded_normalized(&query, "query", MAX_FIND_QUERY_BYTES))
            .transpose()?;
        let source = optional_normalized(self.source, "source", MAX_SOURCE_BYTES)?;
        let manual_section = optional_normalized(
            self.manual_section,
            "manualSection",
            MAX_MANUAL_SECTION_BYTES,
        )?;
        validate_cursor(self.cursor.as_deref())?;
        if source.is_some() && manual_section.is_some() {
            return Err("source and manualSection cannot be combined".to_owned());
        }
        Ok(ValidatedFindParams {
            query,
            kind: self.kind,
            source,
            manual_section,
            cursor: self.cursor,
        })
    }
}

impl OutlineParams {
    pub(super) fn validate(self) -> Result<ValidatedOutlineParams, String> {
        validate_cursor(self.cursor.as_deref())?;
        Ok(ValidatedOutlineParams {
            document: bounded_normalized(&self.document, "document", MAX_DOCUMENT_BYTES)?,
            detail: self.detail.unwrap_or(OutlineDetail::Sections),
            cursor: self.cursor,
        })
    }
}

impl ReadParams {
    pub(super) fn validate(self) -> Result<ValidatedReadParams, String> {
        if self.selectors.is_empty() || self.selectors.len() > MAX_SELECTORS {
            return Err(format!(
                "selectors must contain between 1 and {MAX_SELECTORS} values"
            ));
        }
        let selectors = self
            .selectors
            .into_iter()
            .map(|selector| {
                bounded_normalized(selector.as_str(), "selector", MAX_SELECTOR_BYTES)
                    .map(NodeSelector::new)
            })
            .collect::<Result<_, _>>()?;
        validate_cursor(self.cursor.as_deref())?;
        Ok(ValidatedReadParams {
            document: bounded_normalized(&self.document, "document", MAX_DOCUMENT_BYTES)?,
            selectors,
            cursor: self.cursor,
        })
    }
}

impl ExplainParams {
    pub(super) fn validate(self) -> Result<ValidatedExplainParams, String> {
        validate_cursor(self.cursor.as_deref())?;
        Ok(ValidatedExplainParams {
            scope: validate_scope(
                self.documents,
                self.follow_links,
                self.max_depth,
                self.max_documents,
            )?,
            entry: bounded_normalized(
                &self.entry,
                "entry",
                mant_protocol::MAX_SEMANTIC_ENTRY_BYTES,
            )?,
            cursor: self.cursor,
        })
    }
}

impl SearchParams {
    pub(super) fn validate(self) -> Result<ValidatedSearchParams, String> {
        if self.context_lines > 5 {
            return Err("contextLines must be between 0 and 5".to_owned());
        }
        let limit = self.limit.unwrap_or(DEFAULT_SEARCH_PAGE_SIZE);
        if !(1..=MAX_SEARCH_PAGE_SIZE).contains(&limit) {
            return Err(format!(
                "limit must be between 1 and {MAX_SEARCH_PAGE_SIZE}"
            ));
        }
        validate_cursor(self.cursor.as_deref())?;
        Ok(ValidatedSearchParams {
            scope: validate_scope(
                self.documents,
                self.follow_links,
                self.max_depth,
                self.max_documents,
            )?,
            pattern: bounded_exact(&self.pattern, "pattern", MAX_PATTERN_BYTES)?,
            syntax: self.syntax.unwrap_or_default(),
            case: self.case.unwrap_or_default(),
            word: self.word,
            context_lines: self.context_lines,
            limit,
            cursor: self.cursor,
        })
    }
}

fn validate_scope(
    documents: Vec<String>,
    follow_links: bool,
    max_depth: Option<u16>,
    max_documents: Option<u32>,
) -> Result<DocumentScope, String> {
    if documents.is_empty() || documents.len() > mant_protocol::MAX_SCOPE_DOCUMENTS {
        return Err(format!(
            "documents must contain between 1 and {} values",
            mant_protocol::MAX_SCOPE_DOCUMENTS
        ));
    }
    if !follow_links && (max_depth.is_some() || max_documents.is_some()) {
        return Err("maxDepth and maxDocuments require followLinks=true".to_owned());
    }
    let documents = documents
        .into_iter()
        .map(|document| {
            bounded_normalized(&document, "document", MAX_DOCUMENT_BYTES).map(|selector| {
                DocumentSelector {
                    selector,
                    source: None,
                    manual_section: None,
                }
            })
        })
        .collect::<Result<Vec<_>, _>>()?;
    let effective_max_documents =
        max_documents.unwrap_or(mant_protocol::DEFAULT_SCOPE_DOCUMENT_LIMIT);
    if effective_max_documents < u32::try_from(documents.len()).unwrap_or(u32::MAX)
        || effective_max_documents > mant_protocol::MAX_SCOPE_DOCUMENT_LIMIT
    {
        return Err(format!(
            "maxDocuments must include every initial document and not exceed {}",
            mant_protocol::MAX_SCOPE_DOCUMENT_LIMIT
        ));
    }
    let effective_max_depth = max_depth.unwrap_or(mant_protocol::DEFAULT_SCOPE_DEPTH);
    if effective_max_depth > mant_protocol::MAX_SCOPE_DEPTH {
        return Err(format!(
            "maxDepth must not exceed {}",
            mant_protocol::MAX_SCOPE_DEPTH
        ));
    }
    Ok(DocumentScope {
        documents,
        traversal: DocumentTraversal {
            follow_links,
            max_depth,
            max_documents,
        },
    })
}

pub(super) fn catalog_query(parameters: &ValidatedFindParams, offset: u32) -> CatalogQuery {
    CatalogQuery {
        pattern: parameters.query.clone(),
        syntax: SearchSyntax::Literal,
        case: SearchCase::Insensitive,
        kind: parameters.kind,
        source: parameters.source.clone(),
        manual_section: parameters.manual_section.clone(),
        limit: FIND_PAGE_SIZE,
        offset,
    }
}

pub(super) fn request_for(document: String, view: QueryView) -> QueryRequest {
    QueryRequest {
        schema: mant_protocol::RequestSchema::V0Dot8,
        input: QueryInput::Document {
            selector: document,
            source: None,
            manual_section: None,
        },
        view,
    }
}

fn validate_cursor(value: Option<&str>) -> Result<(), String> {
    if value.is_some_and(|value| value.is_empty() || value.len() > MAX_CURSOR_BYTES) {
        return Err(format!(
            "cursor must contain between 1 and {MAX_CURSOR_BYTES} bytes"
        ));
    }
    Ok(())
}

fn optional_normalized(
    value: Option<String>,
    field: &str,
    max: usize,
) -> Result<Option<String>, String> {
    value
        .map(|value| bounded_normalized(&value, field, max))
        .transpose()
}

fn bounded_normalized(value: &str, field: &str, max: usize) -> Result<String, String> {
    let value = value.trim();
    bounded_exact(value, field, max)
}

fn bounded_exact(value: &str, field: &str, max: usize) -> Result<String, String> {
    if value.is_empty() {
        return Err(format!("{field} must not be empty"));
    }
    if value.len() > max {
        return Err(format!("{field} must not exceed {max} bytes"));
    }
    if value.chars().any(char::is_control) {
        return Err(format!("{field} must not contain control characters"));
    }
    Ok(value.to_owned())
}