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_BYTES, MAX_SCOPE_CONTENT_BYTES, MAX_SCOPE_DEPTH,
11 MAX_SCOPE_DOCUMENT_LIMIT, MAX_SCOPE_DOCUMENTS, MAX_SEMANTIC_ENTRY_BYTES, 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, search_query, select_explanation,
20 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 ContentLimit,
45 TraversalLimitsRequireLinks,
47 DocumentSelector(ScopeTextError),
49 EntrySelector(ScopeTextError),
51 Search(crate::SearchError),
53 NoResolvedDocuments {
55 reasons: Vec<String>,
57 },
58}
59
60impl fmt::Display for ScopeQueryError {
61 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
62 match self {
63 Self::EmptyScope => formatter.write_str("at least one document is required"),
64 Self::TooManyDocuments => write!(
65 formatter,
66 "at most {MAX_SCOPE_DOCUMENTS} initial documents are allowed"
67 ),
68 Self::DepthLimit => write!(
69 formatter,
70 "maximum link depth must not exceed {MAX_SCOPE_DEPTH}"
71 ),
72 Self::DocumentLimit => write!(
73 formatter,
74 "document limit must include every initial document and not exceed {MAX_SCOPE_DOCUMENT_LIMIT}"
75 ),
76 Self::ContentLimit => write!(
77 formatter,
78 "initial documents exceed the {} MiB scope content limit",
79 MAX_SCOPE_CONTENT_BYTES / (1024 * 1024)
80 ),
81 Self::TraversalLimitsRequireLinks => {
82 formatter.write_str("maxDepth and maxDocuments require followLinks=true")
83 }
84 Self::DocumentSelector(error) => {
85 write!(
86 formatter,
87 "document selector {}",
88 scope_text_error_message(*error)
89 )
90 }
91 Self::EntrySelector(error) => {
92 write!(
93 formatter,
94 "semantic entry {}",
95 scope_text_error_message(*error)
96 )
97 }
98 Self::Search(error) => error.fmt(formatter),
99 Self::NoResolvedDocuments { reasons } => {
100 formatter.write_str("none of the initial documents could be resolved")?;
101 if !reasons.is_empty() {
102 write!(formatter, ": {}", reasons.join("; "))?;
103 }
104 Ok(())
105 }
106 }
107 }
108}
109
110impl Error for ScopeQueryError {
111 fn source(&self) -> Option<&(dyn Error + 'static)> {
112 match self {
113 Self::Search(error) => Some(error),
114 Self::EmptyScope
115 | Self::TooManyDocuments
116 | Self::DepthLimit
117 | Self::DocumentLimit
118 | Self::ContentLimit
119 | Self::TraversalLimitsRequireLinks
120 | Self::DocumentSelector(_)
121 | Self::EntrySelector(_)
122 | Self::NoResolvedDocuments { .. } => None,
123 }
124 }
125}
126
127pub fn validate_scope_query_request(request: &ScopeQueryRequest) -> Result<(), ScopeQueryError> {
133 validate_document_scope(&request.scope)?;
134 match &request.view {
135 ScopeQueryView::Explain { entry } => validate_scope_text(entry, MAX_SEMANTIC_ENTRY_BYTES)
136 .map_err(ScopeQueryError::EntrySelector),
137 ScopeQueryView::Search {
138 pattern,
139 syntax,
140 case,
141 scope,
142 word,
143 context_lines,
144 limit,
145 offset,
146 } => validate_search_query(&SearchQuery {
147 pattern: pattern.clone(),
148 syntax: *syntax,
149 case: *case,
150 scope: *scope,
151 word: *word,
152 context_lines: *context_lines,
153 limit: *limit,
154 offset: *offset,
155 })
156 .map_err(ScopeQueryError::Search),
157 }
158}
159
160fn validate_document_scope(scope: &DocumentScope) -> Result<(), ScopeQueryError> {
161 if scope.documents.is_empty() {
162 return Err(ScopeQueryError::EmptyScope);
163 }
164 if scope.documents.len() > MAX_SCOPE_DOCUMENTS {
165 return Err(ScopeQueryError::TooManyDocuments);
166 }
167 for selector in &scope.documents {
168 validate_scope_text(&selector.selector, MAX_DOCUMENT_SELECTOR_BYTES)
169 .map_err(ScopeQueryError::DocumentSelector)?;
170 }
171 if !scope.traversal.follow_links
172 && (scope.traversal.max_depth.is_some() || scope.traversal.max_documents.is_some())
173 {
174 return Err(ScopeQueryError::TraversalLimitsRequireLinks);
175 }
176 if scope.traversal.effective_max_depth() > MAX_SCOPE_DEPTH {
177 return Err(ScopeQueryError::DepthLimit);
178 }
179 let root_count = u32::try_from(scope.documents.len()).unwrap_or(u32::MAX);
180 if scope.traversal.effective_max_documents() < root_count
181 || scope.traversal.effective_max_documents() > MAX_SCOPE_DOCUMENT_LIMIT
182 {
183 return Err(ScopeQueryError::DocumentLimit);
184 }
185 Ok(())
186}
187
188fn scope_text_error_message(error: ScopeTextError) -> String {
189 match error {
190 ScopeTextError::Empty => "must not be empty".to_owned(),
191 ScopeTextError::ControlCharacter => "must not contain control characters".to_owned(),
192 ScopeTextError::TooLong { maximum } => {
193 format!("must not exceed {maximum} bytes")
194 }
195 }
196}
197
198impl DocumentResolver {
199 pub fn resolve_scope(
206 &self,
207 query: &DocumentScope,
208 ) -> Result<LoadedDocumentScope, ScopeQueryError> {
209 validate_document_scope(query)?;
210 let mut resolution = ScopeResolution::new(query);
211 resolution.resolve_roots(self)?;
212 if resolution.documents.is_empty() {
213 return Err(ScopeQueryError::NoResolvedDocuments {
214 reasons: resolution
215 .graph
216 .unresolved
217 .iter()
218 .map(|failure| failure.reason.clone())
219 .collect(),
220 });
221 }
222 if query.traversal.follow_links {
223 resolution.follow_links(self);
224 }
225 Ok(resolution.finish())
226 }
227
228 pub fn execute_scope_query(
235 &self,
236 request: &ScopeQueryRequest,
237 ) -> Result<ScopeQueryResponse, ScopeQueryError> {
238 validate_scope_query_request(request)?;
239 let loaded = self.resolve_scope(&request.scope)?;
240 let result = match &request.view {
241 ScopeQueryView::Explain { entry } => execute_scope_explain(&loaded, entry),
242 ScopeQueryView::Search {
243 pattern,
244 syntax,
245 case,
246 scope,
247 word,
248 context_lines,
249 limit,
250 offset,
251 } => execute_scope_search(
252 &loaded,
253 &SearchQuery {
254 pattern: pattern.clone(),
255 syntax: *syntax,
256 case: *case,
257 scope: *scope,
258 word: *word,
259 context_lines: *context_lines,
260 limit: *limit,
261 offset: *offset,
262 },
263 )?,
264 };
265 Ok(ScopeQueryResponse {
266 schema: ScopeQuerySchema::V0Dot9,
267 scope: loaded.scope,
268 result,
269 })
270 }
271
272 fn resolve_selector(
273 &self,
274 selector: &DocumentSelector,
275 policy: QueryPolicy,
276 ) -> Result<ResolvedContent, QueryError> {
277 self.resolve(
278 &QueryRequest {
279 schema: RequestSchema::V0Dot9,
280 input: QueryInput::Document {
281 selector: selector.selector.clone(),
282 source: selector.source.clone(),
283 manual_section: selector.manual_section.clone(),
284 },
285 view: mant_protocol::QueryView::Full {},
286 },
287 policy,
288 )
289 }
290}
291
292struct ScopeResolution {
293 graph: ResolvedDocumentScope,
294 documents: Vec<ResolvedContent>,
295 positions: BTreeMap<DocumentAddress, usize>,
296 queue: VecDeque<usize>,
297 content_bytes: u64,
298}
299
300impl ScopeResolution {
301 fn new(query: &DocumentScope) -> Self {
302 Self {
303 graph: ResolvedDocumentScope {
304 query: query.clone(),
305 documents: Vec::new(),
306 edges: Vec::new(),
307 frontier: Vec::new(),
308 unresolved: Vec::new(),
309 },
310 documents: Vec::new(),
311 positions: BTreeMap::new(),
312 queue: VecDeque::new(),
313 content_bytes: 0,
314 }
315 }
316
317 fn resolve_roots(&mut self, resolver: &DocumentResolver) -> Result<(), ScopeQueryError> {
318 for (root_index, selector) in self.graph.query.documents.clone().iter().enumerate() {
319 match resolver.resolve_selector(selector, QueryPolicy::Combined) {
320 Ok(bundle) => {
321 if !self.insert_root(bundle, selector, root_index) {
322 return Err(ScopeQueryError::ContentLimit);
323 }
324 }
325 Err(error) => self.graph.unresolved.push(UnresolvedDocument {
326 from: None,
327 selector: selector.clone(),
328 reason: error.to_string(),
329 }),
330 }
331 }
332 Ok(())
333 }
334
335 fn insert_root(
336 &mut self,
337 bundle: ResolvedContent,
338 selector: &DocumentSelector,
339 root_index: usize,
340 ) -> bool {
341 let Some(address) = bundle.address.clone() else {
342 self.graph.unresolved.push(UnresolvedDocument {
343 from: None,
344 selector: selector.clone(),
345 reason: "selector did not resolve to a registered document".to_owned(),
346 });
347 return true;
348 };
349 let root_index = u16::try_from(root_index).unwrap_or(u16::MAX);
350 if let Some(position) = self.positions.get(&address).copied() {
351 let roots = &mut self.graph.documents[position].root_indices;
352 if !roots.contains(&root_index) {
353 roots.push(root_index);
354 }
355 return true;
356 }
357 if !self.reserve_content_bytes(&bundle) {
358 return false;
359 }
360 let position = self.documents.len();
361 self.positions.insert(address.clone(), position);
362 self.documents.push(bundle);
363 self.graph.documents.push(ScopedDocument {
364 address,
365 depth: 0,
366 root_indices: vec![root_index],
367 reached_from: Vec::new(),
368 });
369 self.queue.push_back(position);
370 true
371 }
372
373 fn follow_links(&mut self, resolver: &DocumentResolver) {
374 while let Some(position) = self.queue.pop_front() {
375 let depth = self.graph.documents[position].depth;
376 if depth >= self.graph.query.traversal.effective_max_depth() {
377 self.record_depth_frontier(position);
378 continue;
379 }
380 let from = self.graph.documents[position].address.clone();
381 for reference in document_references(&self.documents[position]) {
382 self.follow_reference(resolver, &from, depth, &reference);
383 }
384 }
385 }
386
387 fn record_depth_frontier(&mut self, position: usize) {
388 let from = self.graph.documents[position].address.clone();
389 for reference in document_references(&self.documents[position]) {
390 if let Some(address) = reference.exact_address(&from) {
391 let edge = DocumentEdge {
392 from: from.clone(),
393 to: address,
394 kind: reference.kind,
395 };
396 if self.record_existing_edge(&edge) {
397 continue;
398 }
399 }
400 self.record_frontier(&from, &reference, TraversalLimit::MaxDepth);
401 }
402 }
403
404 fn follow_reference(
405 &mut self,
406 resolver: &DocumentResolver,
407 from: &DocumentAddress,
408 depth: u16,
409 reference: &DocumentReference,
410 ) {
411 if let Some(address) = reference.exact_address(from) {
412 let edge = DocumentEdge {
413 from: from.clone(),
414 to: address.clone(),
415 kind: reference.kind,
416 };
417 if self.record_existing_edge(&edge) {
418 return;
419 }
420 if self.at_document_limit() {
421 self.record_frontier(from, reference, TraversalLimit::MaxDocuments);
422 return;
423 }
424 } else if self.at_document_limit() {
425 self.record_frontier(from, reference, TraversalLimit::MaxDocuments);
426 return;
427 }
428
429 let Some(selector) = reference.selector(from) else {
430 self.graph.unresolved.push(UnresolvedDocument {
431 from: Some(from.clone()),
432 selector: reference.fallback_selector(),
433 reason: "relative document link escapes its registered namespace".to_owned(),
434 });
435 return;
436 };
437 let policy = if reference.kind == DocumentEdgeKind::Manual {
438 QueryPolicy::ManualOnly
439 } else {
440 QueryPolicy::Combined
441 };
442 let bundle = match resolver.resolve_selector(&selector, policy) {
443 Ok(bundle) => bundle,
444 Err(error) => {
445 self.graph.unresolved.push(UnresolvedDocument {
446 from: Some(from.clone()),
447 selector,
448 reason: error.to_string(),
449 });
450 return;
451 }
452 };
453 let Some(address) = bundle.address.clone() else {
454 self.graph.unresolved.push(UnresolvedDocument {
455 from: Some(from.clone()),
456 selector,
457 reason: "link did not resolve to a registered document".to_owned(),
458 });
459 return;
460 };
461 let edge = DocumentEdge {
462 from: from.clone(),
463 to: address.clone(),
464 kind: reference.kind,
465 };
466 if self.record_existing_edge(&edge) {
467 return;
468 }
469 if self.at_document_limit() {
470 self.record_frontier(from, reference, TraversalLimit::MaxDocuments);
471 return;
472 }
473 if !self.insert_linked(bundle, address, from, depth + 1, edge) {
474 self.record_frontier(from, reference, TraversalLimit::MaxContentBytes);
475 }
476 }
477
478 fn record_existing_edge(&mut self, edge: &DocumentEdge) -> bool {
479 let Some(position) = self.positions.get(&edge.to).copied() else {
480 return false;
481 };
482 if !self.graph.edges.contains(edge) {
483 self.graph.edges.push(edge.clone());
484 }
485 if edge.to != edge.from
486 && !self.graph.documents[position]
487 .reached_from
488 .contains(&edge.from)
489 {
490 self.graph.documents[position]
491 .reached_from
492 .push(edge.from.clone());
493 }
494 true
495 }
496
497 fn insert_linked(
498 &mut self,
499 bundle: ResolvedContent,
500 address: DocumentAddress,
501 from: &DocumentAddress,
502 depth: u16,
503 edge: DocumentEdge,
504 ) -> bool {
505 if !self.reserve_content_bytes(&bundle) {
506 return false;
507 }
508 if !self.graph.edges.contains(&edge) {
509 self.graph.edges.push(edge);
510 }
511 let position = self.documents.len();
512 self.positions.insert(address.clone(), position);
513 self.documents.push(bundle);
514 self.graph.documents.push(ScopedDocument {
515 address,
516 depth,
517 root_indices: Vec::new(),
518 reached_from: vec![from.clone()],
519 });
520 self.queue.push_back(position);
521 true
522 }
523
524 fn reserve_content_bytes(&mut self, bundle: &ResolvedContent) -> bool {
525 let bytes = normalized_content_bytes(bundle);
526 let Some(total) = self.content_bytes.checked_add(bytes) else {
527 return false;
528 };
529 if total > MAX_SCOPE_CONTENT_BYTES {
530 return false;
531 }
532 self.content_bytes = total;
533 true
534 }
535
536 fn at_document_limit(&self) -> bool {
537 u32::try_from(self.documents.len()).unwrap_or(u32::MAX)
538 >= self.graph.query.traversal.effective_max_documents()
539 }
540
541 fn record_frontier(
542 &mut self,
543 from: &DocumentAddress,
544 reference: &DocumentReference,
545 limit: TraversalLimit,
546 ) {
547 let frontier = DocumentFrontier {
548 from: from.clone(),
549 target: reference
550 .selector(from)
551 .unwrap_or_else(|| reference.fallback_selector()),
552 kind: reference.kind,
553 limit,
554 };
555 if !self.graph.frontier.contains(&frontier) {
556 self.graph.frontier.push(frontier);
557 }
558 }
559
560 fn finish(self) -> LoadedDocumentScope {
561 LoadedDocumentScope {
562 scope: self.graph,
563 documents: self.documents,
564 }
565 }
566}
567
568fn normalized_content_bytes(content: &ResolvedContent) -> u64 {
573 let mut counter = ByteCounter::default();
574 if let Some(document) = &content.document {
575 serde_json::to_writer(&mut counter, document)
576 .expect("writing normalized document bytes to a counter cannot fail");
577 }
578 if let Some(tldr) = &content.tldr {
579 serde_json::to_writer(&mut counter, tldr)
580 .expect("writing normalized tldr bytes to a counter cannot fail");
581 }
582 counter.0
583}
584
585#[derive(Default)]
586struct ByteCounter(u64);
587
588impl Write for ByteCounter {
589 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
590 self.0 = self
591 .0
592 .saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
593 Ok(bytes.len())
594 }
595
596 fn flush(&mut self) -> std::io::Result<()> {
597 Ok(())
598 }
599}
600
601fn execute_scope_explain(loaded: &LoadedDocumentScope, entry: &str) -> ScopeQueryResult {
602 let mut matches = Vec::new();
603 let mut missed = 0_u32;
604 let mut failures = Vec::new();
605 for (scoped, bundle) in loaded.scope.documents.iter().zip(&loaded.documents) {
606 match select_explanation(bundle, entry) {
607 Ok(excerpt) => matches.push(ScopedExplanation {
608 address: scoped.address.clone(),
609 depth: scoped.depth,
610 excerpt,
611 }),
612 Err(ProjectionError::UnknownSelector { .. }) => {
613 missed = missed.saturating_add(1);
614 }
615 Err(error) => failures.push(ScopedQueryFailure {
616 address: scoped.address.clone(),
617 reason: error.to_string(),
618 }),
619 }
620 }
621 ScopeQueryResult::Explain {
622 entry: entry.to_owned(),
623 matches,
624 missed,
625 failures,
626 }
627}
628
629fn execute_scope_search(
630 loaded: &LoadedDocumentScope,
631 query: &SearchQuery,
632) -> Result<ScopeQueryResult, ScopeQueryError> {
633 let mut total = 0_u32;
634 let mut remaining_skip = query.offset;
635 let mut remaining_take = query.limit;
636 let mut groups = Vec::new();
637 for (scoped, bundle) in loaded.scope.documents.iter().zip(&loaded.documents) {
638 let local = search_query(
639 bundle,
640 &SearchQuery {
641 offset: remaining_skip,
642 limit: remaining_take.max(1),
643 ..query.clone()
644 },
645 )
646 .map_err(ScopeQueryError::Search)?;
647 total = total.saturating_add(local.total);
648 remaining_skip = remaining_skip.saturating_sub(local.total);
649 if remaining_take == 0 || local.matches.is_empty() {
650 continue;
651 }
652 let mut local = local;
653 let mut hits = std::mem::take(&mut local.matches);
654 if u32::try_from(hits.len()).unwrap_or(u32::MAX) > remaining_take {
655 hits.truncate(usize::try_from(remaining_take).unwrap_or(usize::MAX));
656 }
657 remaining_take =
658 remaining_take.saturating_sub(u32::try_from(hits.len()).unwrap_or(u32::MAX));
659 local.returned = u32::try_from(hits.len()).unwrap_or(u32::MAX);
660 local.matches = hits;
661 groups.push(ScopedSearchDocument {
662 address: scoped.address.clone(),
663 depth: scoped.depth,
664 search: local,
665 });
666 }
667 let returned = query.limit.saturating_sub(remaining_take);
668 let end = query.offset.saturating_add(returned);
669 Ok(ScopeQueryResult::Search {
670 search: ScopeSearch {
671 query: query.clone(),
672 total,
673 returned,
674 offset: query.offset,
675 truncated: end < total,
676 next_offset: (end < total).then_some(end),
677 documents: groups,
678 },
679 })
680}
681
682#[derive(Clone)]
683struct DocumentReference {
684 target: LinkTarget,
685 kind: DocumentEdgeKind,
686}
687
688impl DocumentReference {
689 fn exact_address(&self, from: &DocumentAddress) -> Option<DocumentAddress> {
690 match &self.target {
691 LinkTarget::Document { name, .. } => from.resolve_document_reference(name),
692 LinkTarget::Manual {
693 name,
694 manual_section: Some(manual_section),
695 } => Some(DocumentAddress::Manual {
696 name: name.clone(),
697 manual_section: manual_section.clone(),
698 }),
699 LinkTarget::Manual {
700 manual_section: None,
701 ..
702 }
703 | LinkTarget::External { .. }
704 | LinkTarget::Email { .. }
705 | LinkTarget::Section { .. } => None,
706 }
707 }
708
709 fn selector(&self, from: &DocumentAddress) -> Option<DocumentSelector> {
710 match &self.target {
711 LinkTarget::Document { name, .. } => {
712 let address = from.resolve_document_reference(name)?;
713 Some(DocumentSelector {
714 selector: address.catalog_path(),
715 source: None,
716 manual_section: None,
717 })
718 }
719 LinkTarget::Manual {
720 name,
721 manual_section,
722 } => Some(DocumentSelector {
723 selector: name.clone(),
724 source: None,
725 manual_section: manual_section.clone(),
726 }),
727 LinkTarget::External { .. } | LinkTarget::Email { .. } | LinkTarget::Section { .. } => {
728 None
729 }
730 }
731 }
732
733 fn fallback_selector(&self) -> DocumentSelector {
734 let selector = match &self.target {
735 LinkTarget::Document { name, .. } | LinkTarget::Manual { name, .. } => name.clone(),
736 LinkTarget::External { uri } => uri.clone(),
737 LinkTarget::Email { address } => address.clone(),
738 LinkTarget::Section { id } => id.to_string(),
739 };
740 DocumentSelector {
741 selector,
742 source: None,
743 manual_section: None,
744 }
745 }
746}
747
748fn document_references(bundle: &ResolvedContent) -> Vec<DocumentReference> {
749 struct Collector {
750 references: Vec<DocumentReference>,
751 }
752 impl<'ir> Visit<'ir> for Collector {
753 fn visit_inline(&mut self, inline: &'ir Inline) {
754 if let Inline::Link { target, .. } = inline {
755 let kind = match target {
756 LinkTarget::Document { .. } => Some(DocumentEdgeKind::Document),
757 LinkTarget::Manual { .. } => Some(DocumentEdgeKind::Manual),
758 LinkTarget::External { .. }
759 | LinkTarget::Email { .. }
760 | LinkTarget::Section { .. } => None,
761 };
762 if let Some(kind) = kind {
763 self.references.push(DocumentReference {
764 target: target.clone(),
765 kind,
766 });
767 }
768 }
769 walk_inline(self, inline);
770 }
771 }
772 let mut collector = Collector {
773 references: Vec::new(),
774 };
775 if let Some(document) = bundle.document.as_ref() {
776 collector.visit_document(document);
777 }
778 collector.references
779}
780
781#[cfg(test)]
782mod tests {
783 use mant_ir::{DocumentAddress, MarkdownOrigin};
784
785 use super::*;
786
787 #[test]
788 fn scope_bounds_include_every_root() {
789 let scope = DocumentScope {
790 documents: vec![
791 DocumentSelector {
792 selector: "a".to_owned(),
793 source: None,
794 manual_section: None,
795 },
796 DocumentSelector {
797 selector: "b".to_owned(),
798 source: None,
799 manual_section: None,
800 },
801 ],
802 traversal: mant_protocol::DocumentTraversal {
803 follow_links: true,
804 max_documents: Some(1),
805 ..mant_protocol::DocumentTraversal::default()
806 },
807 };
808 assert_eq!(
809 validate_document_scope(&scope),
810 Err(ScopeQueryError::DocumentLimit)
811 );
812 }
813
814 #[test]
815 fn explicit_traversal_limits_require_link_following() {
816 let scope = DocumentScope {
817 documents: vec![DocumentSelector {
818 selector: "a".to_owned(),
819 source: None,
820 manual_section: None,
821 }],
822 traversal: mant_protocol::DocumentTraversal {
823 follow_links: false,
824 max_depth: Some(0),
825 max_documents: None,
826 },
827 };
828 assert_eq!(
829 validate_document_scope(&scope),
830 Err(ScopeQueryError::TraversalLimitsRequireLinks)
831 );
832 }
833
834 #[test]
835 fn native_scope_request_enforces_document_selector_contract() {
836 let mut scope = DocumentScope {
837 documents: vec![DocumentSelector {
838 selector: "a".repeat(MAX_DOCUMENT_SELECTOR_BYTES + 1),
839 source: None,
840 manual_section: None,
841 }],
842 traversal: mant_protocol::DocumentTraversal::default(),
843 };
844 assert_eq!(
845 validate_document_scope(&scope),
846 Err(ScopeQueryError::DocumentSelector(ScopeTextError::TooLong {
847 maximum: MAX_DOCUMENT_SELECTOR_BYTES,
848 }))
849 );
850
851 scope.documents[0].selector = "root\nother".to_owned();
852 assert_eq!(
853 validate_document_scope(&scope),
854 Err(ScopeQueryError::DocumentSelector(
855 ScopeTextError::ControlCharacter
856 ))
857 );
858 }
859
860 #[test]
861 fn native_scope_request_enforces_entry_selector_contract() {
862 let request = ScopeQueryRequest {
863 schema: mant_protocol::ScopeRequestSchema::V0Dot9,
864 scope: DocumentScope {
865 documents: vec![DocumentSelector {
866 selector: "root".to_owned(),
867 source: None,
868 manual_section: None,
869 }],
870 traversal: mant_protocol::DocumentTraversal::default(),
871 },
872 view: ScopeQueryView::Explain {
873 entry: "x".repeat(MAX_SEMANTIC_ENTRY_BYTES + 1),
874 },
875 };
876 assert_eq!(
877 validate_scope_query_request(&request),
878 Err(ScopeQueryError::EntrySelector(ScopeTextError::TooLong {
879 maximum: MAX_SEMANTIC_ENTRY_BYTES,
880 }))
881 );
882 }
883
884 #[test]
885 fn relative_links_use_the_current_markdown_namespace() {
886 let reference = DocumentReference {
887 target: LinkTarget::Document {
888 name: "../other".to_owned(),
889 fragment: None,
890 },
891 kind: DocumentEdgeKind::Document,
892 };
893 let from = DocumentAddress::Markdown {
894 path: "guide/start".to_owned(),
895 origin: MarkdownOrigin::Documents,
896 };
897 assert_eq!(
898 reference.selector(&from).map(|selector| selector.selector),
899 Some("documents/other".to_owned())
900 );
901 }
902
903 #[test]
904 fn frontier_retains_unresolved_manual_targets_without_inventing_an_address() {
905 let scope = DocumentScope {
906 documents: vec![DocumentSelector {
907 selector: "root".to_owned(),
908 source: None,
909 manual_section: Some("1".to_owned()),
910 }],
911 traversal: mant_protocol::DocumentTraversal {
912 follow_links: true,
913 max_depth: None,
914 max_documents: Some(1),
915 },
916 };
917 let from = DocumentAddress::Manual {
918 name: "root".to_owned(),
919 manual_section: "1".to_owned(),
920 };
921 let reference = DocumentReference {
922 target: LinkTarget::Manual {
923 name: "child".to_owned(),
924 manual_section: None,
925 },
926 kind: DocumentEdgeKind::Manual,
927 };
928 let mut resolution = ScopeResolution::new(&scope);
929 resolution.record_frontier(&from, &reference, TraversalLimit::MaxDocuments);
930
931 assert_eq!(resolution.graph.frontier.len(), 1);
932 assert_eq!(resolution.graph.frontier[0].target.selector, "child");
933 assert_eq!(resolution.graph.frontier[0].target.manual_section, None);
934 assert_eq!(
935 resolution.graph.frontier[0].limit,
936 TraversalLimit::MaxDocuments
937 );
938 }
939
940 #[test]
941 fn normalized_content_budget_refuses_another_document_before_retaining_it() {
942 let scope = DocumentScope {
943 documents: vec![DocumentSelector {
944 selector: "root".to_owned(),
945 source: None,
946 manual_section: None,
947 }],
948 traversal: mant_protocol::DocumentTraversal::default(),
949 };
950 let content =
951 crate::query_markdown_text("# Child\n\nBody.\n", None).expect("fixture content");
952 let bytes = normalized_content_bytes(&content);
953 assert!(bytes > 0 && bytes <= MAX_SCOPE_CONTENT_BYTES);
954
955 let mut resolution = ScopeResolution::new(&scope);
956 resolution.content_bytes = MAX_SCOPE_CONTENT_BYTES - bytes + 1;
957
958 assert!(!resolution.reserve_content_bytes(&content));
959 assert_eq!(
960 resolution.content_bytes,
961 MAX_SCOPE_CONTENT_BYTES - bytes + 1
962 );
963 }
964}