Skip to main content

mant_engine/
scope.rs

1//! Application-level scope request validation and query execution.
2use crate::DocumentResolver;
3use mant_loader::{LoadedDocumentScope, ScopeLoadError, validate_document_scope};
4use mant_protocol::{
5    DocumentScope, MAX_SEMANTIC_ENTRY_CHARS, ScopeQueryRequest, ScopeQueryResult, ScopeQueryView,
6    ScopeTextError, SearchQuery, validate_scope_text,
7};
8use mant_query::validate_search_query;
9use std::{error::Error, fmt};
10
11mod execute;
12pub use execute::{PreparedScopeQuery, execute_scope_query};
13
14/// Invalid query configuration, failed scope loading, or query execution failure.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum ScopeQueryError {
17    /// Source selection or initial-document acquisition failed.
18    Load(ScopeLoadError),
19    /// Pure collection-query execution failed after loading.
20    Execution(mant_query::ScopeExecutionError),
21    /// A loading result did not satisfy the collection-query mapping contract.
22    InvalidLoadedScope(mant_query::ScopeInputError),
23    /// A semantic-entry selector violated its native bound.
24    EntrySelector(ScopeTextError),
25    /// Invalid explanation result/content bounds.
26    Explanation(mant_query::ExplanationError),
27    /// Search configuration was invalid.
28    Search(mant_query::SearchError),
29}
30impl fmt::Display for ScopeQueryError {
31    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32        match self {
33            Self::Load(error) => error.fmt(formatter),
34            Self::Execution(error) => error.fmt(formatter),
35            Self::InvalidLoadedScope(error) => error.fmt(formatter),
36            Self::EntrySelector(error) => write!(
37                formatter,
38                "semantic entry {}",
39                scope_text_error_message(*error)
40            ),
41            Self::Explanation(error) => error.fmt(formatter),
42            Self::Search(error) => error.fmt(formatter),
43        }
44    }
45}
46impl Error for ScopeQueryError {
47    fn source(&self) -> Option<&(dyn Error + 'static)> {
48        match self {
49            Self::Load(error) => Some(error),
50            Self::Execution(error) => Some(error),
51            Self::InvalidLoadedScope(error) => Some(error),
52            Self::Explanation(error) => Some(error),
53            Self::Search(error) => Some(error),
54            Self::EntrySelector(_) => None,
55        }
56    }
57}
58impl DocumentResolver {
59    /// Resolve typed links against this application's existing loader snapshot.
60    ///
61    /// # Errors
62    ///
63    /// Returns invalid traversal input or failure to load any initial document.
64    pub fn resolve_scope(
65        &self,
66        scope: &DocumentScope,
67    ) -> Result<LoadedDocumentScope, ScopeQueryError> {
68        self.loader()
69            .resolve_scope(scope)
70            .map_err(ScopeQueryError::Load)
71    }
72}
73
74/// Validate the closed scope-query contract before document I/O.
75///
76/// # Errors
77///
78/// Returns the first violated bound or projection invariant.
79pub fn validate_scope_query_request(request: &ScopeQueryRequest) -> Result<(), ScopeQueryError> {
80    validate_document_scope(&request.scope).map_err(ScopeQueryError::Load)?;
81    match &request.view {
82        ScopeQueryView::Explain { entry, options } => {
83            validate_scope_text(entry, MAX_SEMANTIC_ENTRY_CHARS)
84                .map_err(ScopeQueryError::EntrySelector)?;
85            mant_query::validate_explanation_query(&mant_protocol::ExplanationQuery {
86                entry: entry.clone(),
87                options: *options,
88            })
89            .map_err(ScopeQueryError::Explanation)
90        }
91        ScopeQueryView::Search {
92            pattern,
93            syntax,
94            case,
95            scope,
96            word,
97            context_lines,
98            limit,
99            offset,
100        } => validate_search_query(&SearchQuery {
101            pattern: pattern.clone(),
102            syntax: *syntax,
103            case: *case,
104            scope: *scope,
105            word: *word,
106            context_lines: *context_lines,
107            limit: *limit,
108            offset: *offset,
109        })
110        .map_err(ScopeQueryError::Search),
111    }
112}
113
114fn scope_text_error_message(error: ScopeTextError) -> String {
115    match error {
116        ScopeTextError::Empty => "must not be empty".to_owned(),
117        ScopeTextError::ControlCharacter => "must not contain control characters".to_owned(),
118        ScopeTextError::TooLong { maximum } => {
119            format!("must not exceed {maximum} Unicode scalar values")
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use mant_ir::{DocumentAddress, MarkdownOrigin, ResolvedContent};
128    use mant_protocol::{DocumentSelector, ResolvedDocumentScope, ScopedDocument};
129
130    // Query tests own ordinary graph/content fixtures, not loader internals.
131    struct QueryFixture {
132        scope: ResolvedDocumentScope,
133        documents: Vec<ResolvedContent>,
134    }
135
136    #[test]
137    fn native_scope_request_enforces_entry_selector_contract() {
138        let mut request = ScopeQueryRequest {
139            schema: mant_protocol::ScopeRequestSchema::V0Dot11,
140            scope: DocumentScope {
141                documents: vec![DocumentSelector {
142                    selector: "root".to_owned(),
143                    source: None,
144                    manual_section: None,
145                }],
146                traversal: mant_protocol::DocumentTraversal::default(),
147            },
148            view: ScopeQueryView::Explain {
149                entry: "x".repeat(MAX_SEMANTIC_ENTRY_CHARS + 1),
150                options: mant_protocol::ExplanationOptions::default(),
151            },
152        };
153        assert_eq!(
154            validate_scope_query_request(&request),
155            Err(ScopeQueryError::EntrySelector(ScopeTextError::TooLong {
156                maximum: MAX_SEMANTIC_ENTRY_CHARS,
157            }))
158        );
159
160        request.view = ScopeQueryView::Explain {
161            entry: "界".repeat(MAX_SEMANTIC_ENTRY_CHARS),
162            options: mant_protocol::ExplanationOptions::default(),
163        };
164        assert_eq!(validate_scope_query_request(&request), Ok(()));
165    }
166
167    #[test]
168    fn scope_explain_retains_a_visible_text_probe_as_a_qualified_failure() {
169        let address = DocumentAddress::Markdown {
170            path: "shell".to_owned(),
171            origin: MarkdownOrigin::Documents,
172        };
173        let mut loaded = QueryFixture {
174            scope: ResolvedDocumentScope {
175                query: DocumentScope {
176                    documents: vec![DocumentSelector {
177                        selector: "documents/shell".to_owned(),
178                        source: None,
179                        manual_section: None,
180                    }],
181                    traversal: mant_protocol::DocumentTraversal::default(),
182                },
183                documents: vec![ScopedDocument {
184                    address: address.clone(),
185                    depth: 0,
186                    root_indices: vec![0],
187                    reached_from: Vec::new(),
188                }],
189                edges: Vec::new(),
190                frontier: Vec::new(),
191                unresolved: Vec::new(),
192                reference_limits: Vec::new(),
193            },
194            documents: vec![
195                mant_loader::load_markdown_text(
196                    "# Shell\n\n## Startup\n\nThe `VISUAL` name selects an editor.\n",
197                    Some("shell.md".to_owned()),
198                )
199                .expect("probe fixture"),
200            ],
201        };
202
203        for (source, content) in loaded.scope.documents.iter().zip(&mut loaded.documents) {
204            content.address = Some(source.address.clone());
205        }
206        let explanation = mant_query::explain_scope(
207            mant_query::QueryScopeView::new(&loaded.scope, &loaded.documents).unwrap(),
208            &mant_protocol::ExplanationQuery {
209                entry: "VISUAL".to_owned(),
210                options: mant_protocol::ExplanationOptions::default(),
211            },
212        )
213        .unwrap();
214        assert!(explanation.failures.is_empty());
215        assert_eq!(explanation.total, 1);
216        assert_eq!(explanation.documents[0].address, address);
217        assert_eq!(explanation.evidence[0].document_index, 0);
218        let evidence = &explanation.evidence[0].evidence;
219        assert_eq!(evidence.outline.path(), "1");
220        assert_eq!(evidence.outline.title(), "Startup");
221        assert!(evidence.entry.is_none());
222        assert!(evidence.source.is_some());
223    }
224
225    #[test]
226    fn scope_search_uses_one_global_cursor_and_global_ordinals() {
227        let address = |path: &str| DocumentAddress::Markdown {
228            path: path.to_owned(),
229            origin: MarkdownOrigin::Documents,
230        };
231        let markdown = |title: &str, count: usize| {
232            let body = (1..=count)
233                .map(|index| format!("needle {index}"))
234                .collect::<Vec<_>>()
235                .join("\n\n");
236            mant_loader::load_markdown_text(&format!("# {title}\n\n{body}\n"), None)
237                .expect("search fixture")
238        };
239        let documents = ["alpha", "beta"]
240            .into_iter()
241            .map(|path| ScopedDocument {
242                address: address(path),
243                depth: 0,
244                root_indices: Vec::new(),
245                reached_from: Vec::new(),
246            })
247            .collect::<Vec<_>>();
248        let mut loaded = QueryFixture {
249            scope: ResolvedDocumentScope {
250                query: DocumentScope {
251                    documents: Vec::new(),
252                    traversal: mant_protocol::DocumentTraversal::default(),
253                },
254                documents,
255                edges: Vec::new(),
256                frontier: Vec::new(),
257                unresolved: Vec::new(),
258                reference_limits: Vec::new(),
259            },
260            documents: vec![markdown("Alpha", 3), markdown("Beta", 10)],
261        };
262        for (source, content) in loaded.scope.documents.iter().zip(&mut loaded.documents) {
263            content.address = Some(source.address.clone());
264        }
265        let query = SearchQuery {
266            pattern: "needle".to_owned(),
267            syntax: mant_protocol::SearchSyntax::Literal,
268            case: mant_protocol::SearchCase::Insensitive,
269            scope: mant_protocol::SearchScope::Visible,
270            word: false,
271            context_lines: 0,
272            limit: 5,
273            offset: 0,
274        };
275
276        let search = mant_query::search_scope(
277            mant_query::QueryScopeView::new(&loaded.scope, &loaded.documents).unwrap(),
278            &query,
279        )
280        .expect("scope search");
281
282        assert_eq!(search.total, 13);
283        assert_eq!(search.returned, 5);
284        assert_eq!(search.next_offset, Some(5));
285        assert_eq!(search.documents.len(), 2);
286        assert_eq!(
287            search
288                .documents
289                .iter()
290                .flat_map(|document| document.matches.iter().map(|hit| hit.ordinal))
291                .collect::<Vec<_>>(),
292            [1, 2, 3, 4, 5]
293        );
294
295        let cross_boundary_query = SearchQuery { offset: 2, ..query };
296        let search = mant_query::search_scope(
297            mant_query::QueryScopeView::new(&loaded.scope, &loaded.documents).unwrap(),
298            &cross_boundary_query,
299        )
300        .expect("scope search");
301
302        assert_eq!(search.total, 13);
303        assert_eq!(search.returned, 5);
304        assert_eq!(search.next_offset, Some(7));
305        assert_eq!(search.documents.len(), 2);
306        assert_eq!(
307            search
308                .documents
309                .iter()
310                .flat_map(|document| document.matches.iter().map(|hit| hit.ordinal))
311                .collect::<Vec<_>>(),
312            [3, 4, 5, 6, 7]
313        );
314    }
315}