use mant_protocol::{
CatalogDocumentKind, CatalogQuery, DocumentScope, DocumentSelector, DocumentTraversal,
NodeSelector, OutlineDetail, QueryInput, QueryRequest, QueryView, SearchCase, SearchSyntax,
};
use schemars::JsonSchema;
use serde::Deserialize;
pub(super) const MAX_DOCUMENT_BYTES: usize = mant_protocol::MAX_DOCUMENT_SELECTOR_BYTES;
pub(super) const MAX_CURSOR_BYTES: usize = 256;
pub(super) const MAX_SELECTORS: usize = 16;
pub(super) const FIND_PAGE_SIZE: u32 = 50;
pub(super) const DEFAULT_SEARCH_PAGE_SIZE: u32 = 20;
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;
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct FindParams {
pub(super) query: Option<String>,
pub(super) kind: Option<CatalogDocumentKind>,
#[schemars(length(min = 1))]
pub(super) source: Option<String>,
#[schemars(length(min = 1))]
pub(super) manual_section: Option<String>,
#[schemars(length(min = 1))]
pub(super) cursor: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct OutlineParams {
#[schemars(length(min = 1))]
pub(super) document: String,
pub(super) detail: Option<OutlineDetail>,
#[schemars(length(min = 1))]
pub(super) cursor: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct ReadParams {
#[schemars(length(min = 1))]
pub(super) document: String,
#[schemars(length(min = 1, max = 16))]
pub(super) selectors: Vec<NodeSelector>,
#[schemars(length(min = 1))]
pub(super) cursor: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct ExplainParams {
#[schemars(length(min = 1, max = 16))]
pub(super) documents: Vec<String>,
#[serde(default)]
pub(super) follow_links: bool,
#[schemars(range(max = 32))]
pub(super) max_depth: Option<u16>,
#[schemars(range(min = 1, max = 256))]
pub(super) max_documents: Option<u32>,
#[schemars(length(min = 1))]
pub(super) entry: String,
#[schemars(length(min = 1))]
pub(super) cursor: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct SearchParams {
#[schemars(length(min = 1, max = 16))]
pub(super) documents: Vec<String>,
#[serde(default)]
pub(super) follow_links: bool,
#[schemars(range(max = 32))]
pub(super) max_depth: Option<u16>,
#[schemars(range(min = 1, max = 256))]
pub(super) max_documents: Option<u32>,
#[schemars(length(min = 1))]
pub(super) pattern: String,
pub(super) syntax: Option<SearchSyntax>,
pub(super) case: Option<SearchCase>,
#[serde(default)]
pub(super) word: bool,
#[serde(default)]
#[schemars(range(max = 5))]
pub(super) context_lines: u16,
#[schemars(range(min = 1, max = 100))]
pub(super) limit: Option<u32>,
#[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())
}