1use std::collections::{BTreeMap, VecDeque};
4use std::{error::Error, fmt, io::Write};
5
6use mant_ir::visit::{Visit, walk_inline};
7use mant_ir::{DocumentAddress, Inline, LinkTarget, ResolvedContent};
8use mant_protocol::{
9 DocumentEdge, DocumentEdgeKind, DocumentFrontier, DocumentScope, DocumentSelector,
10 MAX_DOCUMENT_SELECTOR_CHARS, MAX_SCOPE_CONTENT_BYTES, MAX_SCOPE_DEPTH,
11 MAX_SCOPE_DOCUMENT_LIMIT, MAX_SCOPE_DOCUMENTS, MAX_SEMANTIC_ENTRY_CHARS, QueryInput,
12 QueryRequest, RequestSchema, ResolvedDocumentScope, ScopeQueryRequest, ScopeQueryResponse,
13 ScopeQueryResult, ScopeQuerySchema, ScopeQueryView, ScopeSearch, ScopeTextError,
14 ScopedDocument, ScopedExplanation, ScopedQueryFailure, ScopedSearchDocument, SearchQuery,
15 TraversalLimit, UnresolvedDocument, validate_scope_text,
16};
17
18use crate::{
19 DocumentResolver, ProjectionError, QueryError, QueryPolicy,
20 query::select_explanation_with_text_hint, search_query, validate_search_query,
21};
22
23#[derive(Debug, Clone)]
25pub struct LoadedDocumentScope {
26 pub scope: ResolvedDocumentScope,
28 pub documents: Vec<ResolvedContent>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum ScopeQueryError {
35 EmptyScope,
37 TooManyDocuments,
39 DepthLimit,
41 DocumentLimit,
43 TraversalLimitsRequireLinks,
45 DocumentSelector(ScopeTextError),
47 EntrySelector(ScopeTextError),
49 Search(crate::SearchError),
51 NoResolvedDocuments {
53 reasons: Vec<String>,
55 },
56}
57
58impl fmt::Display for ScopeQueryError {
59 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
60 match self {
61 Self::EmptyScope => formatter.write_str("at least one document is required"),
62 Self::TooManyDocuments => write!(
63 formatter,
64 "at most {MAX_SCOPE_DOCUMENTS} initial documents are allowed"
65 ),
66 Self::DepthLimit => write!(
67 formatter,
68 "maximum link depth must not exceed {MAX_SCOPE_DEPTH}"
69 ),
70 Self::DocumentLimit => write!(
71 formatter,
72 "document limit must include every initial document and not exceed {MAX_SCOPE_DOCUMENT_LIMIT}"
73 ),
74 Self::TraversalLimitsRequireLinks => {
75 formatter.write_str("maxDepth and maxDocuments require followLinks=true")
76 }
77 Self::DocumentSelector(error) => {
78 write!(
79 formatter,
80 "document selector {}",
81 scope_text_error_message(*error)
82 )
83 }
84 Self::EntrySelector(error) => {
85 write!(
86 formatter,
87 "semantic entry {}",
88 scope_text_error_message(*error)
89 )
90 }
91 Self::Search(error) => error.fmt(formatter),
92 Self::NoResolvedDocuments { reasons } => {
93 formatter.write_str("none of the initial documents could be resolved")?;
94 if !reasons.is_empty() {
95 write!(formatter, ": {}", reasons.join("; "))?;
96 }
97 Ok(())
98 }
99 }
100 }
101}
102
103impl Error for ScopeQueryError {
104 fn source(&self) -> Option<&(dyn Error + 'static)> {
105 match self {
106 Self::Search(error) => Some(error),
107 Self::EmptyScope
108 | Self::TooManyDocuments
109 | Self::DepthLimit
110 | Self::DocumentLimit
111 | Self::TraversalLimitsRequireLinks
112 | Self::DocumentSelector(_)
113 | Self::EntrySelector(_)
114 | Self::NoResolvedDocuments { .. } => None,
115 }
116 }
117}
118
119pub fn validate_scope_query_request(request: &ScopeQueryRequest) -> Result<(), ScopeQueryError> {
125 validate_document_scope(&request.scope)?;
126 match &request.view {
127 ScopeQueryView::Explain { entry } => validate_scope_text(entry, MAX_SEMANTIC_ENTRY_CHARS)
128 .map_err(ScopeQueryError::EntrySelector),
129 ScopeQueryView::Search {
130 pattern,
131 syntax,
132 case,
133 scope,
134 word,
135 context_lines,
136 limit,
137 offset,
138 } => validate_search_query(&SearchQuery {
139 pattern: pattern.clone(),
140 syntax: *syntax,
141 case: *case,
142 scope: *scope,
143 word: *word,
144 context_lines: *context_lines,
145 limit: *limit,
146 offset: *offset,
147 })
148 .map_err(ScopeQueryError::Search),
149 }
150}
151
152fn validate_document_scope(scope: &DocumentScope) -> Result<(), ScopeQueryError> {
153 if scope.documents.is_empty() {
154 return Err(ScopeQueryError::EmptyScope);
155 }
156 if scope.documents.len() > MAX_SCOPE_DOCUMENTS {
157 return Err(ScopeQueryError::TooManyDocuments);
158 }
159 for selector in &scope.documents {
160 validate_scope_text(&selector.selector, MAX_DOCUMENT_SELECTOR_CHARS)
161 .map_err(ScopeQueryError::DocumentSelector)?;
162 }
163 if !scope.traversal.follow_links
164 && (scope.traversal.max_depth.is_some() || scope.traversal.max_documents.is_some())
165 {
166 return Err(ScopeQueryError::TraversalLimitsRequireLinks);
167 }
168 if scope.traversal.effective_max_depth() > MAX_SCOPE_DEPTH {
169 return Err(ScopeQueryError::DepthLimit);
170 }
171 let root_count = u32::try_from(scope.documents.len()).unwrap_or(u32::MAX);
172 if scope.traversal.effective_max_documents() < root_count
173 || scope.traversal.effective_max_documents() > MAX_SCOPE_DOCUMENT_LIMIT
174 {
175 return Err(ScopeQueryError::DocumentLimit);
176 }
177 Ok(())
178}
179
180fn scope_text_error_message(error: ScopeTextError) -> String {
181 match error {
182 ScopeTextError::Empty => "must not be empty".to_owned(),
183 ScopeTextError::ControlCharacter => "must not contain control characters".to_owned(),
184 ScopeTextError::TooLong { maximum } => {
185 format!("must not exceed {maximum} Unicode scalar values")
186 }
187 }
188}
189
190impl DocumentResolver {
191 pub fn resolve_scope(
198 &self,
199 query: &DocumentScope,
200 ) -> Result<LoadedDocumentScope, ScopeQueryError> {
201 validate_document_scope(query)?;
202 let mut resolution = ScopeResolution::new(query);
203 resolution.resolve_roots(self);
204 if resolution.documents.is_empty() {
205 return Err(ScopeQueryError::NoResolvedDocuments {
206 reasons: resolution
207 .graph
208 .unresolved
209 .iter()
210 .map(|failure| failure.reason.clone())
211 .collect(),
212 });
213 }
214 if query.traversal.follow_links {
215 resolution.follow_links(self);
216 }
217 Ok(resolution.finish())
218 }
219
220 pub fn execute_scope_query(
227 &self,
228 request: &ScopeQueryRequest,
229 ) -> Result<ScopeQueryResponse, ScopeQueryError> {
230 validate_scope_query_request(request)?;
231 let loaded = self.resolve_scope(&request.scope)?;
232 let result = match &request.view {
233 ScopeQueryView::Explain { entry } => execute_scope_explain(&loaded, entry),
234 ScopeQueryView::Search {
235 pattern,
236 syntax,
237 case,
238 scope,
239 word,
240 context_lines,
241 limit,
242 offset,
243 } => execute_scope_search(
244 &loaded,
245 &SearchQuery {
246 pattern: pattern.clone(),
247 syntax: *syntax,
248 case: *case,
249 scope: *scope,
250 word: *word,
251 context_lines: *context_lines,
252 limit: *limit,
253 offset: *offset,
254 },
255 )?,
256 };
257 Ok(ScopeQueryResponse {
258 schema: ScopeQuerySchema::V0Dot10,
259 scope: loaded.scope,
260 result,
261 })
262 }
263
264 fn resolve_selector(
265 &self,
266 selector: &DocumentSelector,
267 policy: QueryPolicy,
268 ) -> Result<ResolvedContent, QueryError> {
269 self.resolve(
270 &QueryRequest {
271 schema: RequestSchema::V0Dot10,
272 input: QueryInput::Document {
273 selector: selector.selector.clone(),
274 source: selector.source.clone(),
275 manual_section: selector.manual_section.clone(),
276 },
277 view: mant_protocol::QueryView::Full {},
278 },
279 policy,
280 )
281 }
282}
283
284struct ScopeResolution {
285 graph: ResolvedDocumentScope,
286 documents: Vec<ResolvedContent>,
287 positions: BTreeMap<DocumentAddress, usize>,
288 queue: VecDeque<usize>,
289 content_bytes: u64,
290}
291
292impl ScopeResolution {
293 fn new(query: &DocumentScope) -> Self {
294 Self {
295 graph: ResolvedDocumentScope {
296 query: query.clone(),
297 documents: Vec::new(),
298 edges: Vec::new(),
299 frontier: Vec::new(),
300 unresolved: Vec::new(),
301 },
302 documents: Vec::new(),
303 positions: BTreeMap::new(),
304 queue: VecDeque::new(),
305 content_bytes: 0,
306 }
307 }
308
309 fn resolve_roots(&mut self, resolver: &DocumentResolver) {
310 for (root_index, selector) in self.graph.query.documents.clone().iter().enumerate() {
311 match resolver.resolve_selector(selector, QueryPolicy::Combined) {
312 Ok(bundle) => {
313 self.insert_root(bundle, selector, root_index);
314 }
315 Err(error) => self.graph.unresolved.push(UnresolvedDocument {
316 from: None,
317 selector: selector.clone(),
318 reason: error.to_string(),
319 }),
320 }
321 }
322 }
323
324 fn insert_root(
325 &mut self,
326 bundle: ResolvedContent,
327 selector: &DocumentSelector,
328 root_index: usize,
329 ) {
330 let Some(address) = bundle.address.clone() else {
331 self.graph.unresolved.push(UnresolvedDocument {
332 from: None,
333 selector: selector.clone(),
334 reason: "selector did not resolve to a registered document".to_owned(),
335 });
336 return;
337 };
338 let root_index = u16::try_from(root_index).unwrap_or(u16::MAX);
339 if let Some(position) = self.positions.get(&address).copied() {
340 let roots = &mut self.graph.documents[position].root_indices;
341 if !roots.contains(&root_index) {
342 roots.push(root_index);
343 }
344 return;
345 }
346 if !self.reserve_content_bytes(&bundle) {
347 self.graph.unresolved.push(UnresolvedDocument {
348 from: None,
349 selector: selector.clone(),
350 reason: format!(
351 "document exceeds the {} MiB aggregate scope content budget",
352 MAX_SCOPE_CONTENT_BYTES / (1024 * 1024)
353 ),
354 });
355 return;
356 }
357 let position = self.documents.len();
358 self.positions.insert(address.clone(), position);
359 self.documents.push(bundle);
360 self.graph.documents.push(ScopedDocument {
361 address,
362 depth: 0,
363 root_indices: vec![root_index],
364 reached_from: Vec::new(),
365 });
366 self.queue.push_back(position);
367 }
368
369 fn follow_links(&mut self, resolver: &DocumentResolver) {
370 while let Some(position) = self.queue.pop_front() {
371 let depth = self.graph.documents[position].depth;
372 if depth >= self.graph.query.traversal.effective_max_depth() {
373 self.record_depth_frontier(position);
374 continue;
375 }
376 let from = self.graph.documents[position].address.clone();
377 for reference in document_references(&self.documents[position]) {
378 self.follow_reference(resolver, &from, depth, &reference);
379 }
380 }
381 }
382
383 fn record_depth_frontier(&mut self, position: usize) {
384 let from = self.graph.documents[position].address.clone();
385 for reference in document_references(&self.documents[position]) {
386 if let Some(address) = reference.exact_address(&from) {
387 let edge = DocumentEdge {
388 from: from.clone(),
389 to: address,
390 kind: reference.kind,
391 };
392 if self.record_existing_edge(&edge) {
393 continue;
394 }
395 }
396 self.record_frontier(&from, &reference, TraversalLimit::MaxDepth);
397 }
398 }
399
400 fn follow_reference(
401 &mut self,
402 resolver: &DocumentResolver,
403 from: &DocumentAddress,
404 depth: u16,
405 reference: &DocumentReference,
406 ) {
407 if let Some(address) = reference.exact_address(from) {
408 let edge = DocumentEdge {
409 from: from.clone(),
410 to: address.clone(),
411 kind: reference.kind,
412 };
413 if self.record_existing_edge(&edge) {
414 return;
415 }
416 if self.at_document_limit() {
417 self.record_frontier(from, reference, TraversalLimit::MaxDocuments);
418 return;
419 }
420 } else if self.at_document_limit() {
421 self.record_frontier(from, reference, TraversalLimit::MaxDocuments);
422 return;
423 }
424
425 let Some(selector) = reference.selector(from) else {
426 self.graph.unresolved.push(UnresolvedDocument {
427 from: Some(from.clone()),
428 selector: reference.fallback_selector(),
429 reason: "relative document link escapes its registered namespace".to_owned(),
430 });
431 return;
432 };
433 let policy = if reference.kind == DocumentEdgeKind::Manual {
434 QueryPolicy::ManualOnly
435 } else {
436 QueryPolicy::Combined
437 };
438 let bundle = match resolver.resolve_selector(&selector, policy) {
439 Ok(bundle) => bundle,
440 Err(error) => {
441 self.graph.unresolved.push(UnresolvedDocument {
442 from: Some(from.clone()),
443 selector,
444 reason: error.to_string(),
445 });
446 return;
447 }
448 };
449 let Some(address) = bundle.address.clone() else {
450 self.graph.unresolved.push(UnresolvedDocument {
451 from: Some(from.clone()),
452 selector,
453 reason: "link did not resolve to a registered document".to_owned(),
454 });
455 return;
456 };
457 let edge = DocumentEdge {
458 from: from.clone(),
459 to: address.clone(),
460 kind: reference.kind,
461 };
462 if self.record_existing_edge(&edge) {
463 return;
464 }
465 if self.at_document_limit() {
466 self.record_frontier(from, reference, TraversalLimit::MaxDocuments);
467 return;
468 }
469 if !self.insert_linked(bundle, address, from, depth + 1, edge) {
470 self.record_frontier(from, reference, TraversalLimit::MaxContentBytes);
471 }
472 }
473
474 fn record_existing_edge(&mut self, edge: &DocumentEdge) -> bool {
475 let Some(position) = self.positions.get(&edge.to).copied() else {
476 return false;
477 };
478 if !self.graph.edges.contains(edge) {
479 self.graph.edges.push(edge.clone());
480 }
481 if edge.to != edge.from
482 && !self.graph.documents[position]
483 .reached_from
484 .contains(&edge.from)
485 {
486 self.graph.documents[position]
487 .reached_from
488 .push(edge.from.clone());
489 }
490 true
491 }
492
493 fn insert_linked(
494 &mut self,
495 bundle: ResolvedContent,
496 address: DocumentAddress,
497 from: &DocumentAddress,
498 depth: u16,
499 edge: DocumentEdge,
500 ) -> bool {
501 if !self.reserve_content_bytes(&bundle) {
502 return false;
503 }
504 if !self.graph.edges.contains(&edge) {
505 self.graph.edges.push(edge);
506 }
507 let position = self.documents.len();
508 self.positions.insert(address.clone(), position);
509 self.documents.push(bundle);
510 self.graph.documents.push(ScopedDocument {
511 address,
512 depth,
513 root_indices: Vec::new(),
514 reached_from: vec![from.clone()],
515 });
516 self.queue.push_back(position);
517 true
518 }
519
520 fn reserve_content_bytes(&mut self, bundle: &ResolvedContent) -> bool {
521 let bytes = normalized_content_bytes(bundle);
522 let Some(total) = self.content_bytes.checked_add(bytes) else {
523 return false;
524 };
525 if total > MAX_SCOPE_CONTENT_BYTES {
526 return false;
527 }
528 self.content_bytes = total;
529 true
530 }
531
532 fn at_document_limit(&self) -> bool {
533 u32::try_from(self.documents.len()).unwrap_or(u32::MAX)
534 >= self.graph.query.traversal.effective_max_documents()
535 }
536
537 fn record_frontier(
538 &mut self,
539 from: &DocumentAddress,
540 reference: &DocumentReference,
541 limit: TraversalLimit,
542 ) {
543 let frontier = DocumentFrontier {
544 from: from.clone(),
545 target: reference
546 .selector(from)
547 .unwrap_or_else(|| reference.fallback_selector()),
548 kind: reference.kind,
549 limit,
550 };
551 if !self.graph.frontier.contains(&frontier) {
552 self.graph.frontier.push(frontier);
553 }
554 }
555
556 fn finish(self) -> LoadedDocumentScope {
557 LoadedDocumentScope {
558 scope: self.graph,
559 documents: self.documents,
560 }
561 }
562}
563
564fn normalized_content_bytes(content: &ResolvedContent) -> u64 {
569 let mut counter = ByteCounter::default();
570 if let Some(document) = &content.document {
571 serde_json::to_writer(&mut counter, document)
572 .expect("writing normalized document bytes to a counter cannot fail");
573 }
574 if let Some(tldr) = &content.tldr {
575 serde_json::to_writer(&mut counter, tldr)
576 .expect("writing normalized tldr bytes to a counter cannot fail");
577 }
578 counter.0
579}
580
581#[derive(Default)]
582struct ByteCounter(u64);
583
584impl Write for ByteCounter {
585 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
586 self.0 = self
587 .0
588 .saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
589 Ok(bytes.len())
590 }
591
592 fn flush(&mut self) -> std::io::Result<()> {
593 Ok(())
594 }
595}
596
597fn execute_scope_explain(loaded: &LoadedDocumentScope, entry: &str) -> ScopeQueryResult {
598 let mut matches = Vec::new();
599 let mut missed = 0_u32;
600 let mut failures = Vec::new();
601 for (scoped, bundle) in loaded.scope.documents.iter().zip(&loaded.documents) {
602 match select_explanation_with_text_hint(bundle, entry) {
603 Ok(excerpt) => matches.push(ScopedExplanation {
604 address: scoped.address.clone(),
605 depth: scoped.depth,
606 excerpt,
607 }),
608 Err(ProjectionError::UnknownSelector { .. }) => {
609 missed = missed.saturating_add(1);
610 }
611 Err(error) => failures.push(ScopedQueryFailure {
612 address: scoped.address.clone(),
613 reason: error.to_string(),
614 }),
615 }
616 }
617 ScopeQueryResult::Explain {
618 entry: entry.to_owned(),
619 matches,
620 missed,
621 failures,
622 }
623}
624
625fn execute_scope_search(
626 loaded: &LoadedDocumentScope,
627 query: &SearchQuery,
628) -> Result<ScopeQueryResult, ScopeQueryError> {
629 let mut total = 0_u32;
630 let mut remaining_skip = query.offset;
631 let mut remaining_take = query.limit;
632 let mut groups = Vec::new();
633 for (scoped, bundle) in loaded.scope.documents.iter().zip(&loaded.documents) {
634 let document_ordinal_base = total;
635 let local = search_query(
636 bundle,
637 &SearchQuery {
638 offset: remaining_skip,
639 limit: remaining_take.max(1),
640 ..query.clone()
641 },
642 )
643 .map_err(ScopeQueryError::Search)?;
644 total = total.saturating_add(local.total);
645 remaining_skip = remaining_skip.saturating_sub(local.total);
646 if remaining_take == 0 || local.matches.is_empty() {
647 continue;
648 }
649 let mut local = local;
650 let mut hits = std::mem::take(&mut local.matches);
651 if u32::try_from(hits.len()).unwrap_or(u32::MAX) > remaining_take {
652 hits.truncate(usize::try_from(remaining_take).unwrap_or(usize::MAX));
653 }
654 for hit in &mut hits {
655 hit.ordinal = document_ordinal_base.saturating_add(hit.ordinal);
656 }
657 remaining_take =
658 remaining_take.saturating_sub(u32::try_from(hits.len()).unwrap_or(u32::MAX));
659 groups.push(ScopedSearchDocument {
660 address: scoped.address.clone(),
661 depth: scoped.depth,
662 render: local.render,
663 matches: hits,
664 });
665 }
666 let returned = query.limit.saturating_sub(remaining_take);
667 let end = query.offset.saturating_add(returned);
668 Ok(ScopeQueryResult::Search {
669 search: ScopeSearch {
670 query: query.clone(),
671 total,
672 returned,
673 offset: query.offset,
674 truncated: end < total,
675 next_offset: (end < total).then_some(end),
676 documents: groups,
677 },
678 })
679}
680
681#[derive(Clone)]
682struct DocumentReference {
683 target: LinkTarget,
684 kind: DocumentEdgeKind,
685}
686
687impl DocumentReference {
688 fn exact_address(&self, from: &DocumentAddress) -> Option<DocumentAddress> {
689 match &self.target {
690 LinkTarget::Document { name, .. } => from.resolve_document_reference(name),
691 LinkTarget::Manual {
692 name,
693 manual_section: Some(manual_section),
694 } => Some(DocumentAddress::Manual {
695 name: name.clone(),
696 manual_section: manual_section.clone(),
697 }),
698 LinkTarget::Manual {
699 manual_section: None,
700 ..
701 }
702 | LinkTarget::External { .. }
703 | LinkTarget::Email { .. }
704 | LinkTarget::Section { .. } => None,
705 }
706 }
707
708 fn selector(&self, from: &DocumentAddress) -> Option<DocumentSelector> {
709 match &self.target {
710 LinkTarget::Document { name, .. } => {
711 let address = from.resolve_document_reference(name)?;
712 Some(DocumentSelector {
713 selector: address.catalog_path(),
714 source: None,
715 manual_section: None,
716 })
717 }
718 LinkTarget::Manual {
719 name,
720 manual_section,
721 } => Some(DocumentSelector {
722 selector: name.clone(),
723 source: None,
724 manual_section: manual_section.clone(),
725 }),
726 LinkTarget::External { .. } | LinkTarget::Email { .. } | LinkTarget::Section { .. } => {
727 None
728 }
729 }
730 }
731
732 fn fallback_selector(&self) -> DocumentSelector {
733 let selector = match &self.target {
734 LinkTarget::Document { name, .. } | LinkTarget::Manual { name, .. } => name.clone(),
735 LinkTarget::External { uri } => uri.clone(),
736 LinkTarget::Email { address } => address.clone(),
737 LinkTarget::Section { id } => id.to_string(),
738 };
739 DocumentSelector {
740 selector,
741 source: None,
742 manual_section: None,
743 }
744 }
745}
746
747fn document_references(bundle: &ResolvedContent) -> Vec<DocumentReference> {
748 struct Collector {
749 references: Vec<DocumentReference>,
750 }
751 impl<'ir> Visit<'ir> for Collector {
752 fn visit_inline(&mut self, inline: &'ir Inline) {
753 if let Inline::Link { target, .. } = inline {
754 let kind = match target {
755 LinkTarget::Document { .. } => Some(DocumentEdgeKind::Document),
756 LinkTarget::Manual { .. } => Some(DocumentEdgeKind::Manual),
757 LinkTarget::External { .. }
758 | LinkTarget::Email { .. }
759 | LinkTarget::Section { .. } => None,
760 };
761 if let Some(kind) = kind {
762 self.references.push(DocumentReference {
763 target: target.clone(),
764 kind,
765 });
766 }
767 }
768 walk_inline(self, inline);
769 }
770 }
771 let mut collector = Collector {
772 references: Vec::new(),
773 };
774 if let Some(document) = bundle.document.as_ref() {
775 collector.visit_document(document);
776 }
777 collector.references
778}
779
780#[cfg(test)]
781mod tests {
782 use mant_ir::{DocumentAddress, MarkdownOrigin};
783
784 use super::*;
785
786 #[test]
787 fn scope_bounds_include_every_root() {
788 let scope = DocumentScope {
789 documents: vec![
790 DocumentSelector {
791 selector: "a".to_owned(),
792 source: None,
793 manual_section: None,
794 },
795 DocumentSelector {
796 selector: "b".to_owned(),
797 source: None,
798 manual_section: None,
799 },
800 ],
801 traversal: mant_protocol::DocumentTraversal {
802 follow_links: true,
803 max_documents: Some(1),
804 ..mant_protocol::DocumentTraversal::default()
805 },
806 };
807 assert_eq!(
808 validate_document_scope(&scope),
809 Err(ScopeQueryError::DocumentLimit)
810 );
811 }
812
813 #[test]
814 fn explicit_traversal_limits_require_link_following() {
815 let scope = DocumentScope {
816 documents: vec![DocumentSelector {
817 selector: "a".to_owned(),
818 source: None,
819 manual_section: None,
820 }],
821 traversal: mant_protocol::DocumentTraversal {
822 follow_links: false,
823 max_depth: Some(0),
824 max_documents: None,
825 },
826 };
827 assert_eq!(
828 validate_document_scope(&scope),
829 Err(ScopeQueryError::TraversalLimitsRequireLinks)
830 );
831 }
832
833 #[test]
834 fn native_scope_request_enforces_document_selector_contract() {
835 let mut scope = DocumentScope {
836 documents: vec![DocumentSelector {
837 selector: "a".repeat(MAX_DOCUMENT_SELECTOR_CHARS + 1),
838 source: None,
839 manual_section: None,
840 }],
841 traversal: mant_protocol::DocumentTraversal::default(),
842 };
843 assert_eq!(
844 validate_document_scope(&scope),
845 Err(ScopeQueryError::DocumentSelector(ScopeTextError::TooLong {
846 maximum: MAX_DOCUMENT_SELECTOR_CHARS,
847 }))
848 );
849
850 scope.documents[0].selector = "界".repeat(MAX_DOCUMENT_SELECTOR_CHARS);
851 assert_eq!(validate_document_scope(&scope), Ok(()));
852
853 scope.documents[0].selector = "root\nother".to_owned();
854 assert_eq!(
855 validate_document_scope(&scope),
856 Err(ScopeQueryError::DocumentSelector(
857 ScopeTextError::ControlCharacter
858 ))
859 );
860 }
861
862 #[test]
863 fn native_scope_request_enforces_entry_selector_contract() {
864 let mut request = ScopeQueryRequest {
865 schema: mant_protocol::ScopeRequestSchema::V0Dot10,
866 scope: DocumentScope {
867 documents: vec![DocumentSelector {
868 selector: "root".to_owned(),
869 source: None,
870 manual_section: None,
871 }],
872 traversal: mant_protocol::DocumentTraversal::default(),
873 },
874 view: ScopeQueryView::Explain {
875 entry: "x".repeat(MAX_SEMANTIC_ENTRY_CHARS + 1),
876 },
877 };
878 assert_eq!(
879 validate_scope_query_request(&request),
880 Err(ScopeQueryError::EntrySelector(ScopeTextError::TooLong {
881 maximum: MAX_SEMANTIC_ENTRY_CHARS,
882 }))
883 );
884
885 request.view = ScopeQueryView::Explain {
886 entry: "界".repeat(MAX_SEMANTIC_ENTRY_CHARS),
887 };
888 assert_eq!(validate_scope_query_request(&request), Ok(()));
889 }
890
891 #[test]
892 fn scope_explain_retains_a_visible_text_probe_as_a_qualified_failure() {
893 let address = DocumentAddress::Markdown {
894 path: "shell".to_owned(),
895 origin: MarkdownOrigin::Documents,
896 };
897 let loaded = LoadedDocumentScope {
898 scope: ResolvedDocumentScope {
899 query: DocumentScope {
900 documents: vec![DocumentSelector {
901 selector: "documents/shell".to_owned(),
902 source: None,
903 manual_section: None,
904 }],
905 traversal: mant_protocol::DocumentTraversal::default(),
906 },
907 documents: vec![ScopedDocument {
908 address: address.clone(),
909 depth: 0,
910 root_indices: vec![0],
911 reached_from: Vec::new(),
912 }],
913 edges: Vec::new(),
914 frontier: Vec::new(),
915 unresolved: Vec::new(),
916 },
917 documents: vec![
918 crate::query_markdown_text(
919 "# Shell\n\n## Startup\n\nThe `VISUAL` name selects an editor.\n",
920 Some("shell.md".to_owned()),
921 )
922 .expect("probe fixture"),
923 ],
924 };
925
926 let ScopeQueryResult::Explain {
927 matches,
928 missed,
929 failures,
930 ..
931 } = execute_scope_explain(&loaded, "VISUAL")
932 else {
933 panic!("explain result");
934 };
935 assert!(matches.is_empty());
936 assert_eq!(missed, 0);
937 assert_eq!(failures.len(), 1);
938 assert_eq!(failures[0].address, address);
939 assert!(failures[0].reason.contains("outline node 1 (Startup)"));
940 assert!(failures[0].reason.contains("at line"));
941 }
942
943 #[test]
944 fn scope_search_uses_one_global_cursor_and_global_ordinals() {
945 let address = |path: &str| DocumentAddress::Markdown {
946 path: path.to_owned(),
947 origin: MarkdownOrigin::Documents,
948 };
949 let markdown = |title: &str, count: usize| {
950 let body = (1..=count)
951 .map(|index| format!("needle {index}"))
952 .collect::<Vec<_>>()
953 .join("\n\n");
954 crate::query_markdown_text(&format!("# {title}\n\n{body}\n"), None)
955 .expect("search fixture")
956 };
957 let documents = ["alpha", "beta"]
958 .into_iter()
959 .map(|path| ScopedDocument {
960 address: address(path),
961 depth: 0,
962 root_indices: Vec::new(),
963 reached_from: Vec::new(),
964 })
965 .collect::<Vec<_>>();
966 let loaded = LoadedDocumentScope {
967 scope: ResolvedDocumentScope {
968 query: DocumentScope {
969 documents: Vec::new(),
970 traversal: mant_protocol::DocumentTraversal::default(),
971 },
972 documents,
973 edges: Vec::new(),
974 frontier: Vec::new(),
975 unresolved: Vec::new(),
976 },
977 documents: vec![markdown("Alpha", 3), markdown("Beta", 10)],
978 };
979 let query = SearchQuery {
980 pattern: "needle".to_owned(),
981 syntax: mant_protocol::SearchSyntax::Literal,
982 case: mant_protocol::SearchCase::Insensitive,
983 scope: mant_protocol::SearchScope::Visible,
984 word: false,
985 context_lines: 0,
986 limit: 5,
987 offset: 0,
988 };
989
990 let ScopeQueryResult::Search { search } =
991 execute_scope_search(&loaded, &query).expect("scope search")
992 else {
993 panic!("search result");
994 };
995
996 assert_eq!(search.total, 13);
997 assert_eq!(search.returned, 5);
998 assert_eq!(search.next_offset, Some(5));
999 assert_eq!(search.documents.len(), 2);
1000 assert_eq!(
1001 search
1002 .documents
1003 .iter()
1004 .flat_map(|document| document.matches.iter().map(|hit| hit.ordinal))
1005 .collect::<Vec<_>>(),
1006 [1, 2, 3, 4, 5]
1007 );
1008
1009 let cross_boundary_query = SearchQuery { offset: 2, ..query };
1010 let ScopeQueryResult::Search { search } =
1011 execute_scope_search(&loaded, &cross_boundary_query).expect("scope search")
1012 else {
1013 panic!("search result");
1014 };
1015
1016 assert_eq!(search.total, 13);
1017 assert_eq!(search.returned, 5);
1018 assert_eq!(search.next_offset, Some(7));
1019 assert_eq!(search.documents.len(), 2);
1020 assert_eq!(
1021 search
1022 .documents
1023 .iter()
1024 .flat_map(|document| document.matches.iter().map(|hit| hit.ordinal))
1025 .collect::<Vec<_>>(),
1026 [3, 4, 5, 6, 7]
1027 );
1028 }
1029
1030 #[test]
1031 fn relative_links_use_the_current_markdown_namespace() {
1032 let reference = DocumentReference {
1033 target: LinkTarget::Document {
1034 name: "../other".to_owned(),
1035 fragment: None,
1036 },
1037 kind: DocumentEdgeKind::Document,
1038 };
1039 let from = DocumentAddress::Markdown {
1040 path: "guide/start".to_owned(),
1041 origin: MarkdownOrigin::Documents,
1042 };
1043 assert_eq!(
1044 reference.selector(&from).map(|selector| selector.selector),
1045 Some("documents/other".to_owned())
1046 );
1047 }
1048
1049 #[test]
1050 fn frontier_retains_unresolved_manual_targets_without_inventing_an_address() {
1051 let scope = DocumentScope {
1052 documents: vec![DocumentSelector {
1053 selector: "root".to_owned(),
1054 source: None,
1055 manual_section: Some("1".to_owned()),
1056 }],
1057 traversal: mant_protocol::DocumentTraversal {
1058 follow_links: true,
1059 max_depth: None,
1060 max_documents: Some(1),
1061 },
1062 };
1063 let from = DocumentAddress::Manual {
1064 name: "root".to_owned(),
1065 manual_section: "1".to_owned(),
1066 };
1067 let reference = DocumentReference {
1068 target: LinkTarget::Manual {
1069 name: "child".to_owned(),
1070 manual_section: None,
1071 },
1072 kind: DocumentEdgeKind::Manual,
1073 };
1074 let mut resolution = ScopeResolution::new(&scope);
1075 resolution.record_frontier(&from, &reference, TraversalLimit::MaxDocuments);
1076
1077 assert_eq!(resolution.graph.frontier.len(), 1);
1078 assert_eq!(resolution.graph.frontier[0].target.selector, "child");
1079 assert_eq!(resolution.graph.frontier[0].target.manual_section, None);
1080 assert_eq!(
1081 resolution.graph.frontier[0].limit,
1082 TraversalLimit::MaxDocuments
1083 );
1084 }
1085
1086 #[test]
1087 fn normalized_content_budget_refuses_another_document_before_retaining_it() {
1088 let scope = DocumentScope {
1089 documents: vec![DocumentSelector {
1090 selector: "root".to_owned(),
1091 source: None,
1092 manual_section: None,
1093 }],
1094 traversal: mant_protocol::DocumentTraversal::default(),
1095 };
1096 let content =
1097 crate::query_markdown_text("# Child\n\nBody.\n", None).expect("fixture content");
1098 let bytes = normalized_content_bytes(&content);
1099 assert!(bytes > 0 && bytes <= MAX_SCOPE_CONTENT_BYTES);
1100
1101 let mut resolution = ScopeResolution::new(&scope);
1102 resolution.content_bytes = MAX_SCOPE_CONTENT_BYTES - bytes + 1;
1103
1104 assert!(!resolution.reserve_content_bytes(&content));
1105 assert_eq!(
1106 resolution.content_bytes,
1107 MAX_SCOPE_CONTENT_BYTES - bytes + 1
1108 );
1109 }
1110
1111 #[test]
1112 fn root_content_budget_is_reported_as_an_unresolved_root() {
1113 let selector = DocumentSelector {
1114 selector: "root".to_owned(),
1115 source: None,
1116 manual_section: None,
1117 };
1118 let scope = DocumentScope {
1119 documents: vec![selector.clone()],
1120 traversal: mant_protocol::DocumentTraversal::default(),
1121 };
1122 let mut content =
1123 crate::query_markdown_text("# Root\n\nBody.\n", None).expect("fixture content");
1124 content.address = Some(DocumentAddress::Markdown {
1125 path: "root".to_owned(),
1126 origin: MarkdownOrigin::Documents,
1127 });
1128 let mut resolution = ScopeResolution::new(&scope);
1129 resolution.content_bytes = MAX_SCOPE_CONTENT_BYTES;
1130
1131 resolution.insert_root(content, &selector, 0);
1132
1133 assert!(resolution.documents.is_empty());
1134 assert_eq!(resolution.graph.unresolved.len(), 1);
1135 assert!(
1136 resolution.graph.unresolved[0]
1137 .reason
1138 .contains("aggregate scope content budget")
1139 );
1140 }
1141}