Skip to main content

mant_loader/catalog/
selection.rs

1//! Validates, matches, ranks, and pages an immutable catalog without source IO.
2
3use super::inventory::{available_catalog_path, compare_precedence, document_summary};
4use super::{AvailableDocument, AvailableDocumentKind, AvailableDocumentOrigin, CatalogError};
5use grep_matcher::Matcher;
6use grep_regex::RegexMatcherBuilder;
7use mant_protocol::{
8    CatalogCoverage, CatalogDocumentKind, CatalogMatchScore, CatalogQuery, CatalogSchema,
9    DocumentCatalog, MAX_CATALOG_PATTERN_CHARS, SearchCase, SearchSyntax,
10};
11use std::collections::BTreeSet;
12
13/// Filter the unified local catalog using one shared CLI, TUI, and MCP policy.
14///
15/// # Errors
16///
17/// Returns a validation or regular-expression error without reading documents.
18pub fn query_available_documents(
19    documents: &[AvailableDocument],
20    query: &CatalogQuery,
21) -> Result<DocumentCatalog, CatalogError> {
22    Ok(PreparedCatalogQuery::new(query)?.apply(documents))
23}
24
25/// Validated filters and one compiled matcher, prepared without source discovery.
26///
27/// Prepare this borrowed query before creating a system loader when invalid
28/// requests must perform no configuration reads. It can be applied repeatedly
29/// to the same explicit catalog or passed to a loader without compiling the
30/// matcher again. This type neither owns nor refreshes a document snapshot.
31pub struct PreparedCatalogQuery<'query> {
32    query: &'query CatalogQuery,
33    compiled_pattern: Option<grep_regex::RegexMatcher>,
34}
35
36impl<'query> PreparedCatalogQuery<'query> {
37    /// Validate bounds and compile the optional literal or regular expression.
38    ///
39    /// # Errors
40    /// Returns invalid filters, bounds or pattern syntax without source IO.
41    pub fn new(query: &'query CatalogQuery) -> Result<Self, CatalogError> {
42        validate_catalog_query(query)?;
43        let compiled_pattern = query
44            .pattern
45            .as_deref()
46            .map(|pattern| build_matcher(pattern, query.syntax, query.case))
47            .transpose()?;
48        Ok(Self {
49            query,
50            compiled_pattern,
51        })
52    }
53
54    /// Filter, rank and page an already materialized catalog without IO.
55    #[must_use]
56    pub fn apply(&self, documents: &[AvailableDocument]) -> DocumentCatalog {
57        let query = self.query;
58        let in_scope = |document: &&AvailableDocument| catalog_scope_matches(document, query);
59        let scope_total = documents.iter().filter(in_scope).count();
60        let mut filtered = documents
61            .iter()
62            .filter(in_scope)
63            .filter_map(|document| {
64                let match_catalog_path = query
65                    .pattern
66                    .as_deref()
67                    .is_some_and(|pattern| pattern.contains('/'));
68                let matched = self.compiled_pattern.as_ref().map_or(Ok(true), |matcher| {
69                    matcher
70                        .is_match(document.name.as_bytes())
71                        .and_then(|matched| {
72                            if matched {
73                                Ok(true)
74                            } else {
75                                matcher.is_match(document.logical_path.as_bytes())
76                            }
77                        })
78                        .and_then(|matched| {
79                            if matched {
80                                Ok(true)
81                            } else if !match_catalog_path {
82                                Ok(false)
83                            } else {
84                                matcher.is_match(available_catalog_path(document).as_bytes())
85                            }
86                        })
87                });
88                matched.ok().filter(|matched| *matched).map(|_| document)
89            })
90            .collect::<Vec<_>>();
91        filtered.sort_by(|left, right| {
92            match_score(left, query)
93                .cmp(&match_score(right, query))
94                .then_with(|| {
95                    left.logical_path
96                        .to_lowercase()
97                        .cmp(&right.logical_path.to_lowercase())
98                })
99                .then_with(|| left.logical_path.cmp(&right.logical_path))
100                .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
101                .then_with(|| left.name.cmp(&right.name))
102                .then_with(|| compare_precedence(left, right))
103                .then_with(|| left.manual_section.cmp(&right.manual_section))
104                .then_with(|| left.origin.cmp(&right.origin))
105        });
106
107        let total = filtered.len();
108        let offset = usize::try_from(query.offset)
109            .unwrap_or(usize::MAX)
110            .min(total);
111        let limit = usize::try_from(query.limit).unwrap_or(usize::MAX);
112        let end = offset.saturating_add(limit).min(total);
113        let coverage = catalog_coverage(documents, scope_total);
114        let documents = filtered[offset..end]
115            .iter()
116            .copied()
117            .map(document_summary)
118            .collect::<Vec<_>>();
119        DocumentCatalog {
120            schema: CatalogSchema::V0Dot11,
121            query: query.clone(),
122            coverage,
123            total: u32::try_from(total).unwrap_or(u32::MAX),
124            returned: u32::try_from(documents.len()).unwrap_or(u32::MAX),
125            offset: u32::try_from(offset).unwrap_or(u32::MAX),
126            truncated: end < total,
127            next_offset: (end < total).then(|| u32::try_from(end).unwrap_or(u32::MAX)),
128            documents,
129        }
130    }
131}
132
133fn catalog_scope_matches(document: &AvailableDocument, query: &CatalogQuery) -> bool {
134    query.kind.is_none_or(|kind| match kind {
135        CatalogDocumentKind::Markdown => document.kind == AvailableDocumentKind::Markdown,
136        CatalogDocumentKind::Manual => document.kind == AvailableDocumentKind::Manual,
137    }) && query.manual_section.as_ref().is_none_or(|section| {
138        document
139            .manual_section
140            .as_ref()
141            .is_some_and(|value| value == section)
142    }) && query.source.as_ref().is_none_or(|source| {
143        matches!(&document.origin, AvailableDocumentOrigin::Source(value) if value == source)
144    })
145}
146
147fn catalog_coverage(documents: &[AvailableDocument], scope_total: usize) -> CatalogCoverage {
148    let mut manual_sections = BTreeSet::new();
149    let mut markdown_sources = BTreeSet::new();
150    let mut personal_documents = false;
151    for document in documents {
152        match &document.origin {
153            AvailableDocumentOrigin::Documents => personal_documents = true,
154            AvailableDocumentOrigin::Source(source) => {
155                markdown_sources.insert(source.clone());
156            }
157            AvailableDocumentOrigin::ManualPath => {
158                if let Some(section) = &document.manual_section {
159                    manual_sections.insert(section.clone());
160                }
161            }
162        }
163    }
164    CatalogCoverage {
165        scope_total: u32::try_from(scope_total).unwrap_or(u32::MAX),
166        manual_sections: manual_sections.into_iter().collect(),
167        markdown_sources: markdown_sources.into_iter().collect(),
168        personal_documents,
169    }
170}
171
172fn validate_catalog_query(query: &CatalogQuery) -> Result<(), CatalogError> {
173    if query.pattern.as_deref().is_some_and(str::is_empty) {
174        return Err(CatalogError::EmptyPattern);
175    }
176    if query
177        .pattern
178        .as_ref()
179        .is_some_and(|pattern| pattern.chars().count() > MAX_CATALOG_PATTERN_CHARS)
180    {
181        return Err(CatalogError::PatternTooLong);
182    }
183    if query.limit == 0 || query.limit > 10_000 {
184        return Err(CatalogError::InvalidLimit);
185    }
186    if query.source.is_some() && query.manual_section.is_some() {
187        return Err(CatalogError::ConflictingSelectors);
188    }
189    if query.source.is_some() && query.kind == Some(CatalogDocumentKind::Manual)
190        || query.manual_section.is_some() && query.kind == Some(CatalogDocumentKind::Markdown)
191    {
192        return Err(CatalogError::ConflictingSelectors);
193    }
194    Ok(())
195}
196
197fn build_matcher(
198    pattern: &str,
199    syntax: SearchSyntax,
200    case: SearchCase,
201) -> Result<grep_regex::RegexMatcher, CatalogError> {
202    let mut builder = RegexMatcherBuilder::new();
203    builder.fixed_strings(syntax == SearchSyntax::Literal);
204    match case {
205        SearchCase::Insensitive => {
206            builder.case_insensitive(true);
207        }
208        SearchCase::Sensitive => {
209            builder.case_insensitive(false);
210        }
211        SearchCase::Smart => {
212            builder.case_smart(true);
213        }
214    }
215    builder
216        .build(pattern)
217        .map_err(|error| CatalogError::InvalidPattern(error.to_string()))
218}
219
220fn match_score(document: &AvailableDocument, query: &CatalogQuery) -> CatalogMatchScore {
221    if query.syntax != SearchSyntax::Literal {
222        return mant_protocol::catalog_literal_match_score("", None, query.case);
223    }
224    let Some(pattern) = query.pattern.as_deref() else {
225        return mant_protocol::catalog_literal_match_score("", None, query.case);
226    };
227    let catalog_path = available_catalog_path(document);
228    [
229        Some(document.name.as_str()),
230        Some(document.logical_path.as_str()),
231        pattern.contains('/').then_some(catalog_path.as_str()),
232    ]
233    .into_iter()
234    .flatten()
235    .map(|candidate| {
236        mant_protocol::catalog_literal_match_score(candidate, Some(pattern), query.case)
237    })
238    .min()
239    .unwrap_or_else(|| mant_protocol::catalog_literal_match_score("", None, query.case))
240}