Skip to main content

mant_loader/
scope_load.rs

1//! Bounded, view-independent loading of typed document scopes.
2use crate::{DocumentLoader, LoadError, LoadPolicy, LoadSpec};
3use mant_ir::{DocumentAddress, DocumentReference, ResolvedContent};
4use mant_protocol::{
5    DocumentEdge, DocumentEdgeKind, DocumentFrontier, DocumentScope, DocumentSelector,
6    MAX_DOCUMENT_SELECTOR_CHARS, MAX_SCOPE_CONTENT_BYTES, MAX_SCOPE_DEPTH,
7    MAX_SCOPE_DOCUMENT_LIMIT, MAX_SCOPE_DOCUMENTS, ResolvedDocumentScope, ScopeTextError,
8    ScopedDocument, TraversalLimit, UnresolvedDocument, validate_scope_text,
9};
10use std::collections::{BTreeMap, BTreeSet, VecDeque};
11use std::{error::Error, fmt, io::Write};
12
13mod references;
14mod resolve;
15#[cfg(test)]
16mod tests;
17
18/// A logical scope together with the loaded documents in matching order.
19#[derive(Debug, Clone)]
20pub struct LoadedDocumentScope {
21    /// Transport-neutral logical graph.
22    scope: ResolvedDocumentScope,
23    /// Loaded documents in the same order as [`ResolvedDocumentScope::documents`].
24    documents: Vec<ResolvedContent>,
25}
26
27impl LoadedDocumentScope {
28    /// Logical graph and source coverage in the loader's stable order.
29    #[must_use]
30    pub const fn scope(&self) -> &ResolvedDocumentScope {
31        &self.scope
32    }
33    /// Immutable original content paired with the logical graph.
34    #[must_use]
35    pub fn documents(&self) -> &[ResolvedContent] {
36        &self.documents
37    }
38    /// Transfer ownership together, without cloning documents.
39    #[must_use]
40    pub fn into_parts(self) -> (ResolvedDocumentScope, Vec<ResolvedContent>) {
41        (self.scope, self.documents)
42    }
43}
44
45/// Invalid loading scope or failure to acquire any initial document.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum ScopeLoadError {
48    /// No initial document was supplied.
49    EmptyScope,
50    /// The initial document count exceeded the native bound.
51    TooManyDocuments,
52    /// Traversal depth exceeded the native bound.
53    DepthLimit,
54    /// The document budget was zero, too large, or smaller than the root set.
55    DocumentLimit,
56    /// Traversal limits were supplied while link following was disabled.
57    TraversalLimitsRequireLinks,
58    /// A logical document selector violated its native bound.
59    DocumentSelector(ScopeTextError),
60    /// No initial document could be loaded.
61    NoResolvedDocuments {
62        /// Compact seed-resolution diagnostics.
63        reasons: Vec<String>,
64    },
65}
66impl fmt::Display for ScopeLoadError {
67    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            Self::EmptyScope => formatter.write_str("at least one document is required"),
70            Self::TooManyDocuments => write!(
71                formatter,
72                "at most {MAX_SCOPE_DOCUMENTS} initial documents are allowed"
73            ),
74            Self::DepthLimit => write!(
75                formatter,
76                "maximum link depth must not exceed {MAX_SCOPE_DEPTH}"
77            ),
78            Self::DocumentLimit => write!(
79                formatter,
80                "document limit must include every initial document and not exceed {MAX_SCOPE_DOCUMENT_LIMIT}"
81            ),
82            Self::TraversalLimitsRequireLinks => {
83                formatter.write_str("maxDepth and maxDocuments require followLinks=true")
84            }
85            Self::DocumentSelector(error) => write!(
86                formatter,
87                "document selector {}",
88                scope_text_error_message(*error)
89            ),
90            Self::NoResolvedDocuments { reasons } => {
91                formatter.write_str("none of the initial documents could be resolved")?;
92                if !reasons.is_empty() {
93                    write!(formatter, ": {}", reasons.join("; "))?;
94                }
95                Ok(())
96            }
97        }
98    }
99}
100impl Error for ScopeLoadError {}
101
102/// Validate source selection and traversal bounds without inspecting a query view.
103///
104/// # Errors
105///
106/// Returns the first violated selector or loading-scope bound.
107pub fn validate_document_scope(scope: &DocumentScope) -> Result<(), ScopeLoadError> {
108    if scope.documents.is_empty() {
109        return Err(ScopeLoadError::EmptyScope);
110    }
111    if scope.documents.len() > MAX_SCOPE_DOCUMENTS {
112        return Err(ScopeLoadError::TooManyDocuments);
113    }
114    for selector in &scope.documents {
115        validate_scope_text(&selector.selector, MAX_DOCUMENT_SELECTOR_CHARS)
116            .map_err(ScopeLoadError::DocumentSelector)?;
117    }
118    if !scope.traversal.follow_links
119        && (scope.traversal.max_depth.is_some() || scope.traversal.max_documents.is_some())
120    {
121        return Err(ScopeLoadError::TraversalLimitsRequireLinks);
122    }
123    if scope.traversal.effective_max_depth() > MAX_SCOPE_DEPTH {
124        return Err(ScopeLoadError::DepthLimit);
125    }
126    let root_count = u32::try_from(scope.documents.len()).unwrap_or(u32::MAX);
127    if scope.traversal.effective_max_documents() < root_count
128        || scope.traversal.effective_max_documents() > MAX_SCOPE_DOCUMENT_LIMIT
129    {
130        return Err(ScopeLoadError::DocumentLimit);
131    }
132    Ok(())
133}
134
135fn scope_text_error_message(error: ScopeTextError) -> String {
136    match error {
137        ScopeTextError::Empty => "must not be empty".to_owned(),
138        ScopeTextError::ControlCharacter => "must not contain control characters".to_owned(),
139        ScopeTextError::TooLong { maximum } => {
140            format!("must not exceed {maximum} Unicode scalar values")
141        }
142    }
143}