mant_loader/catalog/
selection.rs1use 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
13pub fn query_available_documents(
19 documents: &[AvailableDocument],
20 query: &CatalogQuery,
21) -> Result<DocumentCatalog, CatalogError> {
22 Ok(PreparedCatalogQuery::new(query)?.apply(documents))
23}
24
25pub struct PreparedCatalogQuery<'query> {
32 query: &'query CatalogQuery,
33 compiled_pattern: Option<grep_regex::RegexMatcher>,
34}
35
36impl<'query> PreparedCatalogQuery<'query> {
37 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 #[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}