Skip to main content

mant_query/
explanation.rs

1//! Independent bounded evidence collection over immutable content owners.
2mod collect;
3mod literal;
4mod location;
5pub use location::resolve_explanation_block;
6mod details;
7mod matches;
8mod materialize;
9mod page;
10mod plan;
11mod positions;
12mod preview;
13mod relations;
14mod scoped;
15mod support;
16pub(crate) use scoped::explain as explain_scope;
17
18use crate::selectors::{LocatedNode, collect_root_entries, collect_sections};
19use mant_ir::{Block, EntryOwner, NameCase, OutlinePath, ResolvedContent, SourceSpan};
20use mant_protocol::{EvidenceBasis, ExplanationQuery, QueryExplanation};
21
22/// Invalid explanation request or missing readable source.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum ExplanationError {
25    /// Empty, overlong, or control-bearing literal.
26    Entry(mant_protocol::ScopeTextError),
27    /// Result count is outside 1..=256.
28    ResultLimit,
29    /// Content copy budget is outside 1..=4 MiB.
30    ContentLimit,
31    /// No document or quick reference was loaded.
32    MissingContent,
33}
34impl std::fmt::Display for ExplanationError {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            Self::Entry(mant_protocol::ScopeTextError::Empty) => {
38                f.write_str("explanation entry must not be empty")
39            }
40            Self::Entry(mant_protocol::ScopeTextError::ControlCharacter) => {
41                f.write_str("explanation entry must not contain control characters")
42            }
43            Self::Entry(mant_protocol::ScopeTextError::TooLong { maximum }) => write!(
44                f,
45                "explanation entry must not exceed {maximum} Unicode scalar values"
46            ),
47            Self::ResultLimit => f.write_str("explanation limit must be between 1 and 256"),
48            Self::ContentLimit => {
49                f.write_str("explanation content budget must be between 1 and 4194304 bytes")
50            }
51            Self::MissingContent => f.write_str("explanation requires readable content"),
52        }
53    }
54}
55impl std::error::Error for ExplanationError {}
56
57/// Validate literal and copy bounds before loading any source.
58///
59/// # Errors
60/// Returns the first violated request bound.
61pub fn validate_explanation_query(query: &ExplanationQuery) -> Result<(), ExplanationError> {
62    mant_protocol::validate_scope_text(&query.entry, mant_protocol::MAX_SEMANTIC_ENTRY_CHARS)
63        .map_err(ExplanationError::Entry)?;
64    if !(1..=mant_protocol::MAX_EXPLANATION_RESULTS).contains(&query.options.limit) {
65        return Err(ExplanationError::ResultLimit);
66    }
67    if !(1..=mant_protocol::MAX_EXPLANATION_CONTENT_BYTES).contains(&query.options.content_bytes) {
68        return Err(ExplanationError::ContentLimit);
69    }
70    Ok(())
71}
72
73/// Collect independent semantic and literal evidence without unique selection.
74///
75/// Matching names/forms and owner coordinates, direct ordinary content, and
76/// validated explicit relations are kept distinct. This never executes code,
77/// performs I/O, resolves remote value domains or modifies the input tree.
78///
79/// # Errors
80/// Returns invalid request bounds or absence of readable source. Multiple and
81/// zero matching owners are normal results, not navigation errors.
82pub fn explain_query(
83    content: &ResolvedContent,
84    query: &ExplanationQuery,
85) -> Result<QueryExplanation, ExplanationError> {
86    explain_with_usage(content, query).map(|(response, _)| response)
87}
88
89pub(crate) fn explain_with_usage(
90    content: &ResolvedContent,
91    query: &ExplanationQuery,
92) -> Result<(QueryExplanation, u32), ExplanationError> {
93    validate_explanation_query(query)?;
94    let plan = collection_plan(content, query.entry.trim())?;
95    Ok(materialize::response(plan, query))
96}
97
98fn collection_plan<'a>(
99    content: &'a ResolvedContent,
100    entry: &str,
101) -> Result<plan::CollectionPlan<'a>, ExplanationError> {
102    if content.document.is_none() && content.tldr.is_none() {
103        return Err(ExplanationError::MissingContent);
104    }
105    let mut located = Vec::new();
106    if let Some(document) = &content.document {
107        collect_root_entries(&document.blocks, &mut located);
108        collect_sections(&document.sections, &[], &[], &mut located);
109    }
110    let validation = content
111        .document
112        .as_ref()
113        .map(mant_ir::DocumentValidation::new);
114    let (mut candidates, orders, supports) =
115        collect::collect(content, entry, &located, validation.as_ref());
116    let relations = relations::expand(
117        validation.as_ref(),
118        entry,
119        &located,
120        &orders,
121        &mut candidates,
122    );
123    let (candidates, truncated) = candidates.finish();
124    let mut diagnostics = content
125        .document
126        .as_ref()
127        .map(|d| d.diagnostics.clone())
128        .unwrap_or_default();
129    let rejected_aliases = validation
130        .as_ref()
131        .into_iter()
132        .flat_map(mant_ir::DocumentValidation::relation_issues)
133        .filter(|issue| {
134            matches!(
135                issue.kind,
136                mant_ir::EntryRelationIssueKind::AliasOf | mant_ir::EntryRelationIssueKind::Cycle
137            )
138        })
139        .map(|issue| issue.owner.clone())
140        .collect();
141    if let Some(validation) = validation {
142        for diagnostic in validation.into_diagnostics() {
143            if !diagnostics.contains(&diagnostic) {
144                diagnostics.push(diagnostic);
145            }
146        }
147    }
148    Ok(plan::CollectionPlan {
149        content,
150        located,
151        candidates,
152        supports,
153        diagnostics,
154        rejected_aliases,
155        truncation: mant_protocol::ExplanationTruncation {
156            candidates: truncated,
157            relations,
158            content: false,
159        },
160    })
161}
162
163/// Collect semantic evidence with the documented default page/copy budgets.
164/// Use [`explain_query`] for explicit pagination; use [`crate::select_excerpt`]
165/// for strict node navigation. No-evidence is a normal response.
166///
167/// # Errors
168/// Returns invalid literal input or missing readable content.
169pub fn select_explanation(
170    content: &ResolvedContent,
171    entry: &str,
172) -> Result<QueryExplanation, ExplanationError> {
173    explain_query(
174        content,
175        &ExplanationQuery {
176            entry: entry.to_owned(),
177            options: mant_protocol::ExplanationOptions::default(),
178        },
179    )
180}
181
182struct Candidate<'a> {
183    order: usize,
184    located: Option<usize>,
185    ordinary: Option<&'a Block>,
186    section: Option<usize>,
187    block_path: Option<String>,
188    source: Option<SourceSpan>,
189    bases: Vec<EvidenceBasis>,
190    matched: matches::MatchPlan,
191    hits: Vec<preview::LiteralHit<'a>>,
192}
193
194fn same(left: &str, right: &str, case: NameCase) -> bool {
195    match case {
196        NameCase::Sensitive => left == right,
197        NameCase::Insensitive => left.eq_ignore_ascii_case(right),
198    }
199}
200
201fn owner<'a>(node: &LocatedNode<'a>) -> Option<EntryOwner<'a>> {
202    match node {
203        LocatedNode::Entry { entry, .. } => Some(entry.owner()),
204        LocatedNode::Section { .. } => None,
205    }
206}
207
208fn is_identity(node: &LocatedNode<'_>, entry: &str) -> bool {
209    node.id() == entry
210        || entry
211            .parse::<OutlinePath>()
212            .is_ok_and(|path| &path == node.path())
213}