use mant_protocol::{
DocumentCatalog, QueryExcerpt, QueryOutline, QuerySearch, ScopeQueryResponse, ScopeQueryResult,
TraversalLimit, sanitize_terminal_text,
};
use crate::arguments::QueryFormat;
pub(super) const MAX_OUTPUT_BYTES: usize = 32 * 1024;
const CURSOR_FOOTER_RESERVE: usize = 320;
const PAGE_BODY_BYTES: usize = MAX_OUTPUT_BYTES - CURSOR_FOOTER_RESERVE;
#[derive(Debug, PartialEq, Eq)]
pub(super) struct TextPage {
pub(super) text: String,
pub(super) next_byte: Option<u32>,
}
pub(super) fn render_find(catalog: &DocumentCatalog, byte: u32) -> Result<TextPage, String> {
let mut text = format!("{} matches", catalog.total);
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, byte)
}
pub(super) fn render_outline(outline: &QueryOutline, byte: u32) -> Result<TextPage, String> {
page_text(&mant_engine::render_outline_text(outline), byte)
}
pub(super) fn render_excerpt(excerpt: &QueryExcerpt, byte: u32) -> Result<TextPage, String> {
page_text(&mant_engine::render_excerpt_markdown(excerpt), byte)
}
pub(super) fn render_scope_explain(
response: &ScopeQueryResponse,
byte: u32,
) -> 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,
QueryFormat::Markdown,
false,
false,
false,
)
.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);
page_text(&text, byte)
}
pub(super) fn render_scope_search(
response: &ScopeQueryResponse,
byte: u32,
) -> 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,
QueryFormat::Text,
false,
false,
false,
)
.map_err(crate::error::Failure::into_message)?;
if search.returned == 0 {
text = format!(
"0 matches across {} documents",
response.scope.documents.len()
);
}
append_scope_status(&mut text, response);
page_text(&text, byte)
}
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);
}
pub(super) fn finish_page(mut page: TextPage, cursor: Option<&str>) -> String {
if let Some(cursor) = cursor {
if !page.text.is_empty() {
page.text.push_str("\n\n");
}
page.text.push_str("[more cursor=");
page.text.push_str(cursor);
page.text.push(']');
}
debug_assert!(page.text.len() <= MAX_OUTPUT_BYTES);
page.text
}
pub(super) fn page_text(text: &str, byte: u32) -> Result<TextPage, String> {
let start = usize::try_from(byte).map_err(|_| "cursor position is too large".to_owned())?;
if start > text.len() || !text.is_char_boundary(start) {
return Err("cursor no longer addresses this result; restart without it".to_owned());
}
if start == text.len() {
return Ok(TextPage {
text: String::new(),
next_byte: None,
});
}
let hard_end = text.len().min(start.saturating_add(PAGE_BODY_BYTES));
let mut end = floor_char_boundary(text, hard_end);
if end < text.len() {
let minimum = start + (end - start) / 2;
if let Some(boundary) = text[minimum..end].rfind("\n\n") {
end = minimum + boundary + 2;
} else if let Some(boundary) = text[minimum..end].rfind('\n') {
end = minimum + boundary + 1;
}
}
if end == start {
end = floor_char_boundary(text, hard_end.max(start + 1));
}
Ok(TextPage {
text: text[start..end].to_owned(),
next_byte: (end < text.len()).then(|| u32::try_from(end).unwrap_or(u32::MAX)),
})
}
fn floor_char_boundary(text: &str, mut index: usize) -> usize {
index = index.min(text.len());
while index > 0 && !text.is_char_boundary(index) {
index -= 1;
}
index
}
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::{MAX_OUTPUT_BYTES, finish_page, page_text, prepare_scope};
#[test]
fn text_pages_are_utf8_safe_bounded_and_continuable() {
let source = "段落 → content\n\n".repeat(4_000);
let first = page_text(&source, 0).expect("first page");
let next = first.next_byte.expect("continuation");
let token = "c1-r-0000000000000000-0000000000000001";
let rendered = finish_page(first, Some(token));
assert!(rendered.len() <= MAX_OUTPUT_BYTES);
assert!(rendered.ends_with(&format!("[more cursor={token}]")));
let second = page_text(&source, next).expect("second page");
assert!(!second.text.is_empty());
}
#[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 byte = 0;
loop {
let page = page_text(&source, byte).expect("page");
reconstructed.push_str(&page.text);
let Some(next) = page.next_byte else {
break;
};
byte = next;
}
assert_eq!(reconstructed, source);
}
#[test]
fn stale_byte_positions_are_rejected() {
assert!(page_text("é", 1).is_err());
assert!(page_text("short", 99).is_err());
}
#[test]
fn scope_failures_keep_their_selector_guidance_but_mask_controls() {
let mut response = ScopeQueryResponse {
schema: ScopeQuerySchema::V0Dot8,
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)");
}
}