mant 0.9.0

Local-first TUI, structured CLI, and MCP server for manuals and Markdown
Documentation
//! Compact, bounded presentations for the agent-facing MCP boundary.

use std::fmt::Write as _;

use mant_protocol::{
    DocumentCatalog, QueryExcerpt, QueryOutline, QuerySearch, ScopeQueryResponse, ScopeQueryResult,
    TraversalLimit, sanitize_terminal_text,
};

use super::params::PageRequest;
use crate::arguments::QueryFormat;

/// One stateless character page of a canonical rendered result.
#[derive(Debug, PartialEq, Eq)]
pub(super) struct TextPage {
    pub(super) text: String,
    pub(super) start_char: usize,
    pub(super) end_char: usize,
    pub(super) total_chars: usize,
}

pub(super) fn render_find(catalog: &DocumentCatalog, page: PageRequest) -> TextPage {
    let mut text = format!("{} matches", catalog.total);
    if catalog.returned < catalog.total {
        let _ = write!(text, "; {} returned", catalog.returned);
    }
    let records = mant_protocol::render_catalog_text(catalog, false);
    if !records.is_empty() {
        text.push('\n');
        text.push_str(records.trim_end());
    } else if let Some(coverage) = mant_protocol::render_catalog_coverage_text(catalog) {
        text.push_str("; ");
        text.push_str(&coverage.replace('\n', "; "));
    }
    page_text(&text, page)
}

pub(super) fn render_outline(outline: &QueryOutline, page: PageRequest) -> TextPage {
    page_text(&mant_engine::render_outline_text(outline), page)
}

pub(super) fn render_excerpt(excerpt: &QueryExcerpt, page: PageRequest) -> TextPage {
    page_text(&mant_engine::render_excerpt_markdown(excerpt), page)
}

pub(super) fn render_scope_explain(
    response: &ScopeQueryResponse,
    page: PageRequest,
) -> Result<TextPage, String> {
    let ScopeQueryResult::Explain {
        entry,
        matches,
        missed,
        failures,
    } = &response.result
    else {
        return Err("scope response does not contain an explanation".to_owned());
    };
    let mut text = crate::presentation::render_scope_query_result(
        response,
        crate::presentation::RenderOptions {
            format: QueryFormat::Markdown,
            pretty: false,
            preserve_anchors: false,
            color: false,
            target: crate::presentation::OutputTarget::Stream,
        },
    )
    .map_err(crate::error::Failure::into_message)?;
    if matches.is_empty() && failures.is_empty() {
        text = format!(
            "0 matches for semantic entry `{entry}` across {} documents",
            response.scope.documents.len()
        );
    }
    append_status_line(
        &mut text,
        &format!(
            "[explain: matched={}, missed={missed}, failed={}]",
            matches.len(),
            failures.len()
        ),
    );
    append_scope_status(&mut text, response);
    Ok(page_text(&text, page))
}

pub(super) fn render_scope_search(
    response: &ScopeQueryResponse,
    page: PageRequest,
) -> Result<TextPage, String> {
    let ScopeQueryResult::Search { search } = &response.result else {
        return Err("scope response does not contain search results".to_owned());
    };
    let mut text = crate::presentation::render_scope_query_result(
        response,
        crate::presentation::RenderOptions {
            format: QueryFormat::Text,
            pretty: false,
            preserve_anchors: false,
            color: false,
            target: crate::presentation::OutputTarget::Stream,
        },
    )
    .map_err(crate::error::Failure::into_message)?;
    if search.returned == 0 {
        text = format!(
            "0 matches across {} documents",
            response.scope.documents.len()
        );
    }
    append_status_line(
        &mut text,
        &format!(
            "[search: returned={}, total={}]",
            search.returned, search.total
        ),
    );
    append_scope_status(&mut text, response);
    Ok(page_text(&text, page))
}

fn append_scope_status(text: &mut String, response: &ScopeQueryResponse) {
    let unresolved_roots = response
        .scope
        .unresolved
        .iter()
        .filter(|failure| failure.from.is_none())
        .count();
    let unresolved_links = response
        .scope
        .unresolved
        .len()
        .saturating_sub(unresolved_roots);
    let depth_frontier = response
        .scope
        .frontier
        .iter()
        .filter(|edge| edge.limit == TraversalLimit::MaxDepth)
        .count();
    let document_frontier = response
        .scope
        .frontier
        .iter()
        .filter(|edge| edge.limit == TraversalLimit::MaxDocuments)
        .count();
    let content_frontier = response
        .scope
        .frontier
        .iter()
        .filter(|edge| edge.limit == TraversalLimit::MaxContentBytes)
        .count();
    if !response.scope.query.traversal.follow_links
        && unresolved_roots == 0
        && unresolved_links == 0
    {
        return;
    }
    append_status_line(
        text,
        &format!(
            "[scope: documents={}, unresolved-roots={unresolved_roots}, unresolved-links={unresolved_links}, depth-frontier={depth_frontier}, document-frontier={document_frontier}, content-frontier={content_frontier}]",
            response.scope.documents.len()
        ),
    );
}

fn append_status_line(text: &mut String, status: &str) {
    if !text.is_empty() {
        text.push_str("\n\n");
    }
    text.push_str(status);
}

/// Attach the stable, model-visible page metadata to a successful result.
pub(super) fn finish_page(page: &TextPage) -> String {
    let mut output = format!(
        "[mant-page chars={}..{} totalChars={}",
        page.start_char, page.end_char, page.total_chars
    );
    if page.end_char < page.total_chars {
        let _ = write!(output, " nextChar={}", page.end_char);
    }
    output.push(']');
    if !page.text.is_empty() {
        output.push_str("\n\n");
        output.push_str(&page.text);
    }
    output
}

pub(super) fn page_text(text: &str, page: PageRequest) -> TextPage {
    let total_chars = text.chars().count();
    let requested_start = usize::try_from(page.start_char).unwrap_or(usize::MAX);
    let start_char = requested_start.min(total_chars);
    let max_chars = usize::try_from(page.max_chars).unwrap_or(usize::MAX);
    let end_char = start_char.saturating_add(max_chars).min(total_chars);
    let start_byte = char_offset_to_byte(text, start_char, total_chars);
    let end_byte = char_offset_to_byte(text, end_char, total_chars);
    TextPage {
        text: text[start_byte..end_byte].to_owned(),
        start_char,
        end_char,
        total_chars,
    }
}

fn char_offset_to_byte(text: &str, offset: usize, total_chars: usize) -> usize {
    if offset >= total_chars {
        return text.len();
    }
    text.char_indices()
        .nth(offset)
        .map_or(text.len(), |(byte, _)| byte)
}

pub(super) fn prepare_excerpt(excerpt: &mut QueryExcerpt) {
    excerpt.diagnostics.clear();
    discard_document_source_path(&mut excerpt.source);
    for selection in &mut excerpt.selections {
        if let mant_protocol::ExcerptSelection::Tldr { document, .. } = selection {
            document.source_path.clear();
        }
    }
}

pub(super) fn prepare_outline(outline: &mut QueryOutline) {
    outline.diagnostics.clear();
    discard_document_source_path(&mut outline.source);
}

pub(super) fn prepare_search(search: &mut QuerySearch) {
    discard_document_source_path(&mut search.source);
}

pub(super) fn prepare_scope(response: &mut ScopeQueryResponse) {
    for unresolved in &mut response.scope.unresolved {
        "document could not be resolved".clone_into(&mut unresolved.reason);
    }
    match &mut response.result {
        ScopeQueryResult::Explain {
            matches, failures, ..
        } => {
            for found in matches {
                prepare_excerpt(&mut found.excerpt);
            }
            for failure in failures {
                let reason = sanitize_terminal_text(&failure.reason).into_owned();
                reason.clone_into(&mut failure.reason);
            }
        }
        ScopeQueryResult::Search { search } => {
            for found in &mut search.documents {
                prepare_search(&mut found.search);
            }
        }
    }
}

fn discard_document_source_path(source: &mut Option<mant_ir::DocumentSource>) {
    if let Some(source) = source {
        source.path = None;
    }
}

#[cfg(test)]
mod tests {
    use mant_ir::DocumentAddress;
    use mant_protocol::{
        DocumentScope, DocumentSelector, DocumentTraversal, ResolvedDocumentScope,
        ScopeQueryResponse, ScopeQueryResult, ScopeQuerySchema, ScopedQueryFailure,
    };

    use super::{finish_page, page_text, prepare_scope};
    use crate::mcp::params::PageRequest;

    #[test]
    fn text_pages_are_utf8_safe_bounded_and_continuable() {
        let source = "段落 → content\n\n".repeat(40);
        let first = page_text(
            &source,
            PageRequest {
                start_char: 0,
                max_chars: 17,
            },
        );
        assert_eq!(first.text.chars().count(), 17);
        let next = first.end_char;
        let rendered = finish_page(&first);
        assert!(rendered.starts_with(&format!(
            "[mant-page chars=0..17 totalChars={} nextChar=17]",
            source.chars().count()
        )));

        let second = page_text(
            &source,
            PageRequest {
                start_char: u32::try_from(next).expect("small fixture"),
                max_chars: 17,
            },
        );
        assert_eq!(second.text.chars().count(), 17);
    }

    #[test]
    fn text_pages_preserve_all_whitespace_across_continuations() {
        let source = "code  \n\tindented\n\n".repeat(4_000);
        let mut reconstructed = String::new();
        let mut start_char = 0;
        loop {
            let page = page_text(
                &source,
                PageRequest {
                    start_char,
                    max_chars: 997,
                },
            );
            reconstructed.push_str(&page.text);
            if page.end_char == page.total_chars {
                break;
            }
            start_char = u32::try_from(page.end_char).expect("small fixture");
        }

        assert_eq!(reconstructed, source);
    }

    #[test]
    fn character_offsets_are_unicode_scalar_based_and_past_end_is_empty() {
        let page = page_text(
            "aé中→z",
            PageRequest {
                start_char: 1,
                max_chars: 3,
            },
        );
        assert_eq!(page.text, "é中→");
        assert_eq!(
            (page.start_char, page.end_char, page.total_chars),
            (1, 4, 5)
        );

        let empty = page_text(
            "short",
            PageRequest {
                start_char: 99,
                max_chars: 10,
            },
        );
        assert!(empty.text.is_empty());
        assert_eq!((empty.start_char, empty.end_char), (5, 5));
    }

    #[test]
    fn scope_failures_keep_their_selector_guidance_but_mask_controls() {
        let mut response = ScopeQueryResponse {
            schema: ScopeQuerySchema::V0Dot9,
            scope: ResolvedDocumentScope {
                query: DocumentScope {
                    documents: vec![DocumentSelector {
                        selector: "tool".to_owned(),
                        source: None,
                        manual_section: None,
                    }],
                    traversal: DocumentTraversal::default(),
                },
                documents: Vec::new(),
                edges: Vec::new(),
                frontier: Vec::new(),
                unresolved: Vec::new(),
            },
            result: ScopeQueryResult::Explain {
                entry: "-f".to_owned(),
                matches: Vec::new(),
                missed: 0,
                failures: vec![ScopedQueryFailure {
                    address: DocumentAddress::Manual {
                        name: "tool".to_owned(),
                        manual_section: "1".to_owned(),
                    },
                    reason: "multiple entries\u{1b}[2J: 1/e1 (first)".to_owned(),
                }],
            },
        };

        prepare_scope(&mut response);
        let ScopeQueryResult::Explain { failures, .. } = response.result else {
            panic!("fixture must stay an explanation");
        };
        assert_eq!(failures[0].reason, "multiple entries�[2J: 1/e1 (first)");
    }
}