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_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/// 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    /// Traversal limits were supplied while link following was disabled.
44    TraversalLimitsRequireLinks,
45    /// A logical document selector violated its native bound.
46    DocumentSelector(ScopeTextError),
47    /// A semantic-entry selector violated its native bound.
48    EntrySelector(ScopeTextError),
49    /// Search configuration was invalid.
50    Search(crate::SearchError),
51    /// No initial document could be loaded.
52    NoResolvedDocuments {
53        /// Compact seed-resolution diagnostics.
54        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
119/// Validate the closed scope-query contract before document I/O.
120///
121/// # Errors
122///
123/// Returns the first violated bound or projection invariant.
124pub 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    /// Resolve initial documents and their typed outbound links breadth-first.
192    ///
193    /// # Errors
194    ///
195    /// Returns an invalid-scope error, or an aggregate error when no initial
196    /// document is readable. Individual missing links remain in the result.
197    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    /// Resolve a scope and apply its closed multi-document projection.
221    ///
222    /// # Errors
223    ///
224    /// Returns request validation, resolution, or search errors. Ordinary
225    /// per-document explanation misses do not fail the aggregate query.
226    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
564/// Count the retained semantic payload without allocating an additional
565/// serialized copy. The count intentionally follows the normalized IR rather
566/// than compressed or on-disk source bytes: the IR is what scope resolution
567/// retains for all later projections.
568fn 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}