Skip to main content

mant_engine/
scope.rs

1//! Resolves typed document links into bounded, deterministic query scopes.
2
3use 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/// A logical scope together with the loaded documents in matching order.
24#[derive(Debug, Clone)]
25pub struct LoadedDocumentScope {
26    /// Transport-neutral logical graph.
27    pub scope: ResolvedDocumentScope,
28    /// Loaded documents in the same order as [`ResolvedDocumentScope::documents`].
29    pub documents: Vec<ResolvedContent>,
30}
31
32/// Invalid scope configuration or failure to resolve any initial document.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum ScopeQueryError {
35    /// No initial document was supplied.
36    EmptyScope,
37    /// The initial document count exceeded the native bound.
38    TooManyDocuments,
39    /// Traversal depth exceeded the native bound.
40    DepthLimit,
41    /// The document budget was zero, too large, or smaller than the root set.
42    DocumentLimit,
43    /// The initial document set exceeded the aggregate semantic-content budget.
44    ContentLimit,
45    /// Traversal limits were supplied while link following was disabled.
46    TraversalLimitsRequireLinks,
47    /// A logical document selector violated its native bound.
48    DocumentSelector(ScopeTextError),
49    /// A semantic-entry selector violated its native bound.
50    EntrySelector(ScopeTextError),
51    /// Search configuration was invalid.
52    Search(crate::SearchError),
53    /// No initial document could be loaded.
54    NoResolvedDocuments {
55        /// Compact seed-resolution diagnostics.
56        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
127/// Validate the closed scope-query contract before document I/O.
128///
129/// # Errors
130///
131/// Returns the first violated bound or projection invariant.
132pub 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    /// Resolve initial documents and their typed outbound links breadth-first.
200    ///
201    /// # Errors
202    ///
203    /// Returns an invalid-scope error, or an aggregate error when no initial
204    /// document is readable. Individual missing links remain in the result.
205    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    /// Resolve a scope and apply its closed multi-document projection.
229    ///
230    /// # Errors
231    ///
232    /// Returns request validation, resolution, or search errors. Ordinary
233    /// per-document explanation misses do not fail the aggregate query.
234    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
568/// Count the retained semantic payload without allocating an additional
569/// serialized copy. The count intentionally follows the normalized IR rather
570/// than compressed or on-disk source bytes: the IR is what scope resolution
571/// retains for all later projections.
572fn 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}