use super::inventory::{available_catalog_path, compare_precedence, document_summary};
use super::{AvailableDocument, AvailableDocumentKind, AvailableDocumentOrigin, CatalogError};
use grep_matcher::Matcher;
use grep_regex::RegexMatcherBuilder;
use mant_protocol::{
CatalogCoverage, CatalogDocumentKind, CatalogMatchScore, CatalogQuery, CatalogSchema,
DocumentCatalog, MAX_CATALOG_PATTERN_CHARS, SearchCase, SearchSyntax,
};
use std::collections::BTreeSet;
pub fn query_available_documents(
documents: &[AvailableDocument],
query: &CatalogQuery,
) -> Result<DocumentCatalog, CatalogError> {
Ok(PreparedCatalogQuery::new(query)?.apply(documents))
}
pub struct PreparedCatalogQuery<'query> {
query: &'query CatalogQuery,
compiled_pattern: Option<grep_regex::RegexMatcher>,
}
impl<'query> PreparedCatalogQuery<'query> {
pub fn new(query: &'query CatalogQuery) -> Result<Self, CatalogError> {
validate_catalog_query(query)?;
let compiled_pattern = query
.pattern
.as_deref()
.map(|pattern| build_matcher(pattern, query.syntax, query.case))
.transpose()?;
Ok(Self {
query,
compiled_pattern,
})
}
#[must_use]
pub fn apply(&self, documents: &[AvailableDocument]) -> DocumentCatalog {
let query = self.query;
let in_scope = |document: &&AvailableDocument| catalog_scope_matches(document, query);
let scope_total = documents.iter().filter(in_scope).count();
let mut filtered = documents
.iter()
.filter(in_scope)
.filter_map(|document| {
let match_catalog_path = query
.pattern
.as_deref()
.is_some_and(|pattern| pattern.contains('/'));
let matched = self.compiled_pattern.as_ref().map_or(Ok(true), |matcher| {
matcher
.is_match(document.name.as_bytes())
.and_then(|matched| {
if matched {
Ok(true)
} else {
matcher.is_match(document.logical_path.as_bytes())
}
})
.and_then(|matched| {
if matched {
Ok(true)
} else if !match_catalog_path {
Ok(false)
} else {
matcher.is_match(available_catalog_path(document).as_bytes())
}
})
});
matched.ok().filter(|matched| *matched).map(|_| document)
})
.collect::<Vec<_>>();
filtered.sort_by(|left, right| {
match_score(left, query)
.cmp(&match_score(right, query))
.then_with(|| {
left.logical_path
.to_lowercase()
.cmp(&right.logical_path.to_lowercase())
})
.then_with(|| left.logical_path.cmp(&right.logical_path))
.then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
.then_with(|| left.name.cmp(&right.name))
.then_with(|| compare_precedence(left, right))
.then_with(|| left.manual_section.cmp(&right.manual_section))
.then_with(|| left.origin.cmp(&right.origin))
});
let total = filtered.len();
let offset = usize::try_from(query.offset)
.unwrap_or(usize::MAX)
.min(total);
let limit = usize::try_from(query.limit).unwrap_or(usize::MAX);
let end = offset.saturating_add(limit).min(total);
let coverage = catalog_coverage(documents, scope_total);
let documents = filtered[offset..end]
.iter()
.copied()
.map(document_summary)
.collect::<Vec<_>>();
DocumentCatalog {
schema: CatalogSchema::V0Dot11,
query: query.clone(),
coverage,
total: u32::try_from(total).unwrap_or(u32::MAX),
returned: u32::try_from(documents.len()).unwrap_or(u32::MAX),
offset: u32::try_from(offset).unwrap_or(u32::MAX),
truncated: end < total,
next_offset: (end < total).then(|| u32::try_from(end).unwrap_or(u32::MAX)),
documents,
}
}
}
fn catalog_scope_matches(document: &AvailableDocument, query: &CatalogQuery) -> bool {
query.kind.is_none_or(|kind| match kind {
CatalogDocumentKind::Markdown => document.kind == AvailableDocumentKind::Markdown,
CatalogDocumentKind::Manual => document.kind == AvailableDocumentKind::Manual,
}) && query.manual_section.as_ref().is_none_or(|section| {
document
.manual_section
.as_ref()
.is_some_and(|value| value == section)
}) && query.source.as_ref().is_none_or(|source| {
matches!(&document.origin, AvailableDocumentOrigin::Source(value) if value == source)
})
}
fn catalog_coverage(documents: &[AvailableDocument], scope_total: usize) -> CatalogCoverage {
let mut manual_sections = BTreeSet::new();
let mut markdown_sources = BTreeSet::new();
let mut personal_documents = false;
for document in documents {
match &document.origin {
AvailableDocumentOrigin::Documents => personal_documents = true,
AvailableDocumentOrigin::Source(source) => {
markdown_sources.insert(source.clone());
}
AvailableDocumentOrigin::ManualPath => {
if let Some(section) = &document.manual_section {
manual_sections.insert(section.clone());
}
}
}
}
CatalogCoverage {
scope_total: u32::try_from(scope_total).unwrap_or(u32::MAX),
manual_sections: manual_sections.into_iter().collect(),
markdown_sources: markdown_sources.into_iter().collect(),
personal_documents,
}
}
fn validate_catalog_query(query: &CatalogQuery) -> Result<(), CatalogError> {
if query.pattern.as_deref().is_some_and(str::is_empty) {
return Err(CatalogError::EmptyPattern);
}
if query
.pattern
.as_ref()
.is_some_and(|pattern| pattern.chars().count() > MAX_CATALOG_PATTERN_CHARS)
{
return Err(CatalogError::PatternTooLong);
}
if query.limit == 0 || query.limit > 10_000 {
return Err(CatalogError::InvalidLimit);
}
if query.source.is_some() && query.manual_section.is_some() {
return Err(CatalogError::ConflictingSelectors);
}
if query.source.is_some() && query.kind == Some(CatalogDocumentKind::Manual)
|| query.manual_section.is_some() && query.kind == Some(CatalogDocumentKind::Markdown)
{
return Err(CatalogError::ConflictingSelectors);
}
Ok(())
}
fn build_matcher(
pattern: &str,
syntax: SearchSyntax,
case: SearchCase,
) -> Result<grep_regex::RegexMatcher, CatalogError> {
let mut builder = RegexMatcherBuilder::new();
builder.fixed_strings(syntax == SearchSyntax::Literal);
match case {
SearchCase::Insensitive => {
builder.case_insensitive(true);
}
SearchCase::Sensitive => {
builder.case_insensitive(false);
}
SearchCase::Smart => {
builder.case_smart(true);
}
}
builder
.build(pattern)
.map_err(|error| CatalogError::InvalidPattern(error.to_string()))
}
fn match_score(document: &AvailableDocument, query: &CatalogQuery) -> CatalogMatchScore {
if query.syntax != SearchSyntax::Literal {
return mant_protocol::catalog_literal_match_score("", None, query.case);
}
let Some(pattern) = query.pattern.as_deref() else {
return mant_protocol::catalog_literal_match_score("", None, query.case);
};
let catalog_path = available_catalog_path(document);
[
Some(document.name.as_str()),
Some(document.logical_path.as_str()),
pattern.contains('/').then_some(catalog_path.as_str()),
]
.into_iter()
.flatten()
.map(|candidate| {
mant_protocol::catalog_literal_match_score(candidate, Some(pattern), query.case)
})
.min()
.unwrap_or_else(|| mant_protocol::catalog_literal_match_score("", None, query.case))
}