Skip to main content

mant_engine/
query.rs

1//! Resolves local manuals, registered Markdown, and tldr content into one query.
2
3use std::{
4    error::Error,
5    ffi::OsStr,
6    fmt, fs,
7    io::{self, Read},
8    path::{Path, PathBuf},
9    sync::OnceLock,
10};
11
12use mant_ir::{Document, DocumentAddress, MarkdownOrigin, ResolvedContent, TldrDocument};
13use mant_protocol::{
14    CatalogQuery, DocumentCatalog, EntryProjection, InputFormat, MAX_DOCUMENT_SELECTOR_CHARS,
15    MAX_NODE_SELECTORS, MAX_SEMANTIC_ENTRY_CHARS, MAX_SOURCE_SELECTOR_CHARS, QueryExcerpt,
16    QueryInput, QueryOutline, QueryRequest, QuerySearch, QueryView, ScopeTextError, SearchCase,
17    SearchQuery, SearchScope, SearchSyntax, validate_scope_text,
18};
19use mant_sources::{RegisteredDocumentIndex, RegisteredDocumentOrigin, SourceConfigError};
20
21use crate::{
22    ManualIndex, ManualPage, ManualRequest, ProjectionError, SearchError, discover_manual_roots,
23    executable::query_name_candidates, locate_manual_source_in, parse_manual_bytes,
24    parse_manual_page, parse_manual_source, parse_markdown, read_cached_tldr_page, search_query,
25    select_excerpt, select_explanation, validate_search_query,
26};
27
28mod input;
29mod named;
30
31use input::query_with;
32pub use input::{query_markdown_text, query_roff_bytes};
33use named::query_named_document;
34
35/// Upper bound on a single Markdown source, shared by every input path.
36///
37/// File and stdin readers both enforce this so an unbounded source (a pipe, a
38/// character device such as `/dev/zero`, or a pathologically large file) cannot
39/// exhaust memory. A file's reported length is not trusted: some sources report
40/// zero yet stream without end, so readers cap the byte count directly.
41pub const MAX_MARKDOWN_BYTES: u64 = 16 * 1024 * 1024;
42
43/// A query cannot produce either authoritative manual content or a quick reference.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum QueryError {
46    /// A document selector was empty after trimming.
47    EmptyName,
48    /// A native manual category was empty or malformed.
49    InvalidManualSection,
50    /// A tldr command query was qualified by a non-command manual section.
51    TldrManualSection {
52        /// Incompatible native manual section.
53        section: String,
54    },
55    /// An explicit Markdown source name was empty.
56    InvalidSource,
57    /// Markdown-source and native-manual selectors were combined.
58    ConflictingSourceSelectors,
59    /// A direct Markdown input path was empty.
60    EmptyMarkdownPath,
61    /// Automatic format inference did not recognize a direct input.
62    UnsupportedInputFormat {
63        /// Caller-facing input path.
64        path: String,
65    },
66    /// Excerpt projection was requested without selectors.
67    EmptySelection,
68    /// Excerpt projection exceeded the closed selector-count bound.
69    TooManySelections {
70        /// Maximum selectors accepted by one focused request.
71        maximum: usize,
72    },
73    /// An excerpt selector was empty.
74    EmptySelector,
75    /// A role-filtered outline contained no kinds or exceeded the closed kind family.
76    InvalidEntryKinds,
77    /// An explanation entry name was empty.
78    EmptyEntry,
79    /// A node or semantic-entry selector violated the bounded request contract.
80    InvalidViewSelector {
81        /// User-facing field name.
82        field: &'static str,
83        /// Precise bound or character violation.
84        error: ScopeTextError,
85    },
86    /// Search configuration failed validation.
87    InvalidSearch(SearchError),
88    /// Markdown input could not be read or parsed.
89    Markdown {
90        /// Caller-facing source path.
91        path: String,
92        /// Stable failure detail.
93        detail: String,
94    },
95    /// Markdown parsing produced neither document nor tldr content.
96    EmptyMarkdown {
97        /// Selected-document label.
98        label: String,
99    },
100    /// Registered-document discovery failed.
101    Registry {
102        /// Stable source-configuration or discovery detail.
103        detail: String,
104    },
105    /// Native manual loading failed.
106    Manual(ManualLoadError),
107    /// No full document was found, but an optional tldr entry is available.
108    ManualWithTldr {
109        /// Native-manual failure retained as the authoritative lookup error.
110        error: ManualLoadError,
111        /// Topic that can be queried explicitly with `--tldr`.
112        topic: String,
113    },
114    /// An explicit tldr query found no quick-reference candidate.
115    TldrNotFound {
116        /// Requested tldr topic.
117        topic: String,
118    },
119    /// An explicit tldr candidate could not be read or parsed.
120    Tldr {
121        /// Requested tldr topic.
122        topic: String,
123        /// Stable cache or Markdown failure detail.
124        detail: String,
125    },
126    /// No Markdown, manual, or quick-reference content could be resolved.
127    NoReadableContent {
128        /// Requested document name.
129        name: String,
130    },
131}
132
133/// Native-manual resolution or lowering failed after candidate selection.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum ManualLoadError {
136    /// No indexed native manual matched the request.
137    NotFound {
138        /// Requested manual name.
139        name: String,
140        /// Search-path and candidate detail.
141        detail: String,
142    },
143    /// A selected manual could not be parsed or lowered.
144    Parse {
145        /// Requested manual name.
146        name: String,
147        /// Stable parser or source-policy detail.
148        detail: String,
149    },
150    /// Parsing succeeded but produced no readable semantic content.
151    Empty {
152        /// Requested manual name.
153        name: String,
154        /// Physical selected manual path.
155        path: PathBuf,
156        /// Non-fatal parser findings explaining the empty result.
157        diagnostics: Vec<String>,
158    },
159}
160
161/// Materialized result of the view carried by a [`QueryRequest`].
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum QueryViewResult {
164    /// Complete resolved content with no projection.
165    Full(Box<ResolvedContent>),
166    /// Lightweight structural outline.
167    Outline(QueryOutline),
168    /// One or more selected document nodes.
169    Excerpt(QueryExcerpt),
170    /// Paginatable structure-aware search result.
171    Search(QuerySearch),
172}
173
174/// A valid request could not be loaded or projected.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub enum QueryExecutionError {
177    /// Input validation or document loading failure.
178    Query(QueryError),
179    /// Outline or selection projection failure.
180    Projection(ProjectionError),
181    /// Search compilation or execution failure.
182    Search(SearchError),
183}
184
185/// Closed content-resolution policy kept outside the serialized request contract.
186#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
187pub enum QueryPolicy {
188    /// Resolve a full document and attach a compatible quick reference.
189    #[default]
190    Combined,
191    /// Bypass registered Markdown and tldr content.
192    ManualOnly,
193    /// Resolve only embedded or cached tldr content through source precedence.
194    TldrOnly,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198enum FullDocumentMode {
199    Priority,
200    NativeManual,
201    None,
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205enum QuickReferenceMode {
206    AttachToCommandManual,
207    Exclude,
208    Only,
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212struct NamedResolutionPlan {
213    document: FullDocumentMode,
214    quick_reference: QuickReferenceMode,
215}
216
217impl QueryPolicy {
218    fn named_resolution_plan(self, has_manual_section: bool) -> NamedResolutionPlan {
219        match self {
220            Self::Combined => NamedResolutionPlan {
221                document: if has_manual_section {
222                    FullDocumentMode::NativeManual
223                } else {
224                    FullDocumentMode::Priority
225                },
226                quick_reference: QuickReferenceMode::AttachToCommandManual,
227            },
228            Self::ManualOnly => NamedResolutionPlan {
229                document: FullDocumentMode::NativeManual,
230                quick_reference: QuickReferenceMode::Exclude,
231            },
232            Self::TldrOnly => NamedResolutionPlan {
233                document: FullDocumentMode::None,
234                quick_reference: QuickReferenceMode::Only,
235            },
236        }
237    }
238}
239
240impl fmt::Display for QueryError {
241    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
242        match self {
243            Self::EmptyName => formatter.write_str("name must not be empty"),
244            Self::InvalidManualSection => formatter.write_str(
245                "manual section must be a conventional number or the single letter 'l' or 'n'",
246            ),
247            Self::TldrManualSection { section } => write!(
248                formatter,
249                "manual section '{section}' does not identify a command quick reference; tldr supports section families 1 and 8"
250            ),
251            Self::InvalidSource => formatter.write_str("document source must not be empty"),
252            Self::ConflictingSourceSelectors => formatter.write_str(
253                "document source cannot be combined with a manual section or manual-only policy",
254            ),
255            Self::EmptyMarkdownPath => formatter.write_str("Markdown path must not be empty"),
256            Self::UnsupportedInputFormat { path } => write!(
257                formatter,
258                "could not infer the input format for '{path}'; use --input-format markdown or roff"
259            ),
260            Self::EmptySelection => formatter.write_str("at least one outline node is required"),
261            Self::TooManySelections { maximum } => {
262                write!(
263                    formatter,
264                    "outline nodes must not contain more than {maximum} values"
265                )
266            }
267            Self::EmptySelector => formatter.write_str("outline node must not be empty"),
268            Self::InvalidEntryKinds => {
269                formatter.write_str("outline entry kinds must contain between 1 and 9 values")
270            }
271            Self::EmptyEntry => formatter.write_str("semantic entry must not be empty"),
272            Self::InvalidViewSelector { field, error } => {
273                write!(formatter, "{field} {}", view_selector_error_message(*error))
274            }
275            Self::InvalidSearch(error) => error.fmt(formatter),
276            Self::Markdown { path, detail } => {
277                write!(
278                    formatter,
279                    "could not load Markdown document '{path}': {detail}"
280                )
281            }
282            Self::EmptyMarkdown { label } => {
283                write!(
284                    formatter,
285                    "Markdown document '{label}' has no readable content"
286                )
287            }
288            Self::Registry { detail } => formatter.write_str(detail),
289            Self::Manual(error) => error.fmt(formatter),
290            Self::ManualWithTldr { error, topic } => {
291                error.fmt(formatter)?;
292                write!(
293                    formatter,
294                    "\nhint: a tldr entry is available; run `mant {topic} --tldr`"
295                )
296            }
297            Self::TldrNotFound { topic } => {
298                write!(formatter, "no tldr quick reference was found for '{topic}'")
299            }
300            Self::Tldr { topic, detail } => {
301                write!(formatter, "could not load tldr entry '{topic}': {detail}")
302            }
303            Self::NoReadableContent { name } => {
304                write!(
305                    formatter,
306                    "no readable document content was found for '{name}'"
307                )
308            }
309        }
310    }
311}
312
313impl Error for QueryError {
314    fn source(&self) -> Option<&(dyn Error + 'static)> {
315        match self {
316            Self::InvalidSearch(error) => Some(error),
317            Self::Manual(error) | Self::ManualWithTldr { error, .. } => Some(error),
318            Self::EmptyName
319            | Self::InvalidManualSection
320            | Self::TldrManualSection { .. }
321            | Self::InvalidSource
322            | Self::ConflictingSourceSelectors
323            | Self::EmptyMarkdownPath
324            | Self::UnsupportedInputFormat { .. }
325            | Self::EmptySelection
326            | Self::TooManySelections { .. }
327            | Self::EmptySelector
328            | Self::InvalidEntryKinds
329            | Self::EmptyEntry
330            | Self::InvalidViewSelector { .. }
331            | Self::Markdown { .. }
332            | Self::EmptyMarkdown { .. }
333            | Self::Registry { .. }
334            | Self::TldrNotFound { .. }
335            | Self::Tldr { .. }
336            | Self::NoReadableContent { .. } => None,
337        }
338    }
339}
340
341fn view_selector_error_message(error: ScopeTextError) -> String {
342    match error {
343        ScopeTextError::Empty => "must not be empty".to_owned(),
344        ScopeTextError::ControlCharacter => "must not contain control characters".to_owned(),
345        ScopeTextError::TooLong { maximum } => {
346            format!("must not exceed {maximum} Unicode scalar values")
347        }
348    }
349}
350
351impl fmt::Display for ManualLoadError {
352    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
353        match self {
354            Self::NotFound { name, detail } => {
355                write!(formatter, "could not load manual '{name}': {detail}")
356            }
357            Self::Parse { name, detail } => write!(
358                formatter,
359                "could not load manual '{name}': manual source: {detail}"
360            ),
361            Self::Empty {
362                name,
363                path,
364                diagnostics,
365            } => {
366                write!(
367                    formatter,
368                    "could not load manual '{name}': libmandoc parsed {} but produced no readable sections",
369                    path.display()
370                )?;
371                if !diagnostics.is_empty() {
372                    write!(formatter, "; diagnostics: {}", diagnostics.join("; "))?;
373                }
374                Ok(())
375            }
376        }
377    }
378}
379
380impl Error for ManualLoadError {}
381
382impl fmt::Display for QueryExecutionError {
383    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
384        match self {
385            Self::Query(error) => error.fmt(formatter),
386            Self::Projection(error) => error.fmt(formatter),
387            Self::Search(error) => error.fmt(formatter),
388        }
389    }
390}
391
392impl Error for QueryExecutionError {
393    fn source(&self) -> Option<&(dyn Error + 'static)> {
394        match self {
395            Self::Query(error) => Some(error),
396            Self::Projection(error) => Some(error),
397            Self::Search(error) => Some(error),
398        }
399    }
400}
401
402/// Query the local man database and optional offline tldr caches.
403///
404/// # Errors
405///
406/// Returns [`QueryError`] for invalid input or when neither source can produce
407/// readable content.
408pub fn resolve_query(request: &QueryRequest) -> Result<ResolvedContent, QueryError> {
409    resolve_query_with_policy(request, QueryPolicy::default())
410}
411
412/// Query with an explicit input-resolution policy.
413///
414/// # Errors
415///
416/// Returns [`QueryError`] under the same conditions as [`resolve_query`].
417pub fn resolve_query_with_policy(
418    request: &QueryRequest,
419    policy: QueryPolicy,
420) -> Result<ResolvedContent, QueryError> {
421    let resolver = DocumentResolver::from_system();
422    resolver.resolve(request, policy)
423}
424
425/// Load and materialize the view encoded in one native request.
426///
427/// # Errors
428///
429/// Returns a typed loading, projection, or search failure.
430pub fn execute_query(
431    request: &QueryRequest,
432    policy: QueryPolicy,
433) -> Result<QueryViewResult, QueryExecutionError> {
434    let resolver = DocumentResolver::from_system();
435    resolver.execute(request, policy)
436}
437
438/// Materialize one view from an already loaded query.
439///
440/// # Errors
441///
442/// Returns a typed projection or search failure.
443pub fn project_query_view(
444    query: ResolvedContent,
445    view: &QueryView,
446) -> Result<QueryViewResult, QueryExecutionError> {
447    validate_query_view(view).map_err(QueryExecutionError::Query)?;
448    match view {
449        QueryView::Full {} => Ok(QueryViewResult::Full(Box::new(query))),
450        QueryView::Outline { entries, root } => {
451            { crate::projection::build_outline_projection(&query, entries.clone(), root.clone()) }
452                .map(QueryViewResult::Outline)
453                .map_err(QueryExecutionError::Projection)
454        }
455        QueryView::Excerpt { selectors } => select_excerpt(&query, selectors)
456            .map(QueryViewResult::Excerpt)
457            .map_err(QueryExecutionError::Projection),
458        QueryView::Explain { entry } => select_explanation_with_text_hint(&query, entry)
459            .map(QueryViewResult::Excerpt)
460            .map_err(QueryExecutionError::Projection),
461        QueryView::Search {
462            pattern,
463            syntax,
464            case,
465            scope,
466            word,
467            context_lines,
468            limit,
469            offset,
470        } => search_query(
471            &query,
472            &SearchQuery {
473                pattern: pattern.clone(),
474                syntax: *syntax,
475                case: *case,
476                scope: *scope,
477                word: *word,
478                context_lines: *context_lines,
479                limit: *limit,
480                offset: *offset,
481            },
482        )
483        .map(QueryViewResult::Search)
484        .map_err(QueryExecutionError::Search),
485    }
486}
487
488pub(crate) fn select_explanation_with_text_hint(
489    query: &ResolvedContent,
490    entry: &str,
491) -> Result<QueryExcerpt, ProjectionError> {
492    match select_explanation(query, entry) {
493        Err(ProjectionError::UnknownSelector { document, selector }) => {
494            let probe = SearchQuery {
495                pattern: selector.clone(),
496                syntax: SearchSyntax::Literal,
497                case: SearchCase::Insensitive,
498                scope: SearchScope::Visible,
499                word: false,
500                context_lines: 0,
501                limit: 1,
502                offset: 0,
503            };
504            if let Some(found) = search_query(query, &probe)
505                .ok()
506                .and_then(|result| result.matches.into_iter().next())
507            {
508                let line = found
509                    .occurrences
510                    .first()
511                    .map_or(1, |occurrence| occurrence.markdown.start_line);
512                return Err(ProjectionError::SelectorFoundOnlyInText {
513                    document,
514                    selector,
515                    path: found.outline.path().to_owned(),
516                    title: found.outline.title().to_owned(),
517                    line,
518                });
519            }
520            Err(ProjectionError::UnknownSelector { document, selector })
521        }
522        result => result,
523    }
524}
525
526/// Validate all request and policy invariants before local I/O.
527///
528/// # Errors
529///
530/// Returns the exact invalid input constraint.
531pub fn validate_query_request(
532    request: &QueryRequest,
533    policy: QueryPolicy,
534) -> Result<(), QueryError> {
535    match &request.input {
536        QueryInput::Document {
537            selector,
538            source,
539            manual_section,
540        } => {
541            validate_scope_text(selector, MAX_DOCUMENT_SELECTOR_CHARS).map_err(|error| {
542                if error == ScopeTextError::Empty {
543                    QueryError::EmptyName
544                } else {
545                    QueryError::InvalidViewSelector {
546                        field: "document selector",
547                        error,
548                    }
549                }
550            })?;
551            if let Some(source) = source {
552                validate_scope_text(source, MAX_SOURCE_SELECTOR_CHARS).map_err(|error| {
553                    if error == ScopeTextError::Empty {
554                        QueryError::InvalidSource
555                    } else {
556                        QueryError::InvalidViewSelector {
557                            field: "document source",
558                            error,
559                        }
560                    }
561                })?;
562            }
563            if manual_section
564                .as_deref()
565                .is_some_and(|value| !crate::is_manual_section(value.trim()))
566            {
567                return Err(QueryError::InvalidManualSection);
568            }
569            if policy == QueryPolicy::TldrOnly
570                && let Some(section) = manual_section.as_deref()
571                && !crate::is_command_manual_section(section.trim())
572            {
573                return Err(QueryError::TldrManualSection {
574                    section: section.trim().to_owned(),
575                });
576            }
577            if source.is_some() && (manual_section.is_some() || policy == QueryPolicy::ManualOnly) {
578                return Err(QueryError::ConflictingSourceSelectors);
579            }
580        }
581        QueryInput::File { path, .. } => {
582            if path.trim().is_empty() {
583                return Err(QueryError::EmptyMarkdownPath);
584            }
585            if policy != QueryPolicy::Combined {
586                return Err(QueryError::Markdown {
587                    path: path.trim().to_owned(),
588                    detail: "content-only policies do not apply to direct input".to_owned(),
589                });
590            }
591        }
592    }
593    validate_query_view(&request.view)
594}
595
596fn validate_query_view(view: &QueryView) -> Result<(), QueryError> {
597    match view {
598        QueryView::Excerpt { selectors } => {
599            if selectors.is_empty() {
600                return Err(QueryError::EmptySelection);
601            }
602            if selectors.len() > MAX_NODE_SELECTORS {
603                return Err(QueryError::TooManySelections {
604                    maximum: MAX_NODE_SELECTORS,
605                });
606            }
607            for selector in selectors {
608                validate_scope_text(selector, MAX_SEMANTIC_ENTRY_CHARS).map_err(|error| {
609                    if error == ScopeTextError::Empty {
610                        QueryError::EmptySelector
611                    } else {
612                        QueryError::InvalidViewSelector {
613                            field: "outline node",
614                            error,
615                        }
616                    }
617                })?;
618            }
619        }
620        QueryView::Explain { entry } => {
621            validate_scope_text(entry, MAX_SEMANTIC_ENTRY_CHARS).map_err(|error| {
622                if error == ScopeTextError::Empty {
623                    QueryError::EmptyEntry
624                } else {
625                    QueryError::InvalidViewSelector {
626                        field: "semantic entry",
627                        error,
628                    }
629                }
630            })?;
631        }
632        QueryView::Search {
633            pattern,
634            syntax,
635            case,
636            scope,
637            word,
638            context_lines,
639            limit,
640            offset,
641        } => validate_search_query(&SearchQuery {
642            pattern: pattern.clone(),
643            syntax: *syntax,
644            case: *case,
645            scope: *scope,
646            word: *word,
647            context_lines: *context_lines,
648            limit: *limit,
649            offset: *offset,
650        })
651        .map_err(QueryError::InvalidSearch)?,
652        QueryView::Outline { entries, root } => {
653            if let Some(selector) = root {
654                validate_scope_text(selector, MAX_SEMANTIC_ENTRY_CHARS).map_err(|error| {
655                    if error == ScopeTextError::Empty {
656                        QueryError::EmptySelector
657                    } else {
658                        QueryError::InvalidViewSelector {
659                            field: "outline root",
660                            error,
661                        }
662                    }
663                })?;
664            }
665            if let EntryProjection::Kinds { kinds } = entries
666                && (kinds.is_empty() || kinds.len() > 9)
667            {
668                return Err(QueryError::InvalidEntryKinds);
669            }
670        }
671        QueryView::Full {} => {}
672    }
673    Ok(())
674}
675
676trait QueryHost {
677    fn name_candidates(&self, name: &str) -> Vec<String>;
678    fn locate_registered_document(
679        &self,
680        candidates: &[String],
681        source: Option<&str>,
682        phase: RegisteredLookupPhase,
683    ) -> Result<Option<RegisteredSelection>, String>;
684    fn locate_registered_document_groups(
685        &self,
686        candidates: &[String],
687        source: Option<&str>,
688        phase: RegisteredLookupPhase,
689    ) -> Result<Vec<RegisteredSelectionGroup>, String>;
690    fn locate_registered_address(
691        &self,
692        address: &DocumentAddress,
693    ) -> Result<Option<RegisteredSelection>, String>;
694    fn locate_manual(&self, request: &ManualRequest) -> Result<ManualPage, String>;
695    fn parse_manual(&self, page: &ManualPage) -> Result<Document, String>;
696    fn parse_manual_input(&self, path: &Path) -> Result<Document, String>;
697    fn read_tldr(&self, name: &str) -> Result<Option<TldrDocument>, String>;
698    fn read_markdown(&self, path: &Path) -> Result<String, String>;
699}
700
701#[derive(Clone, Copy)]
702enum RegisteredLookupPhase {
703    BeforeBuiltin,
704    AfterBuiltin,
705}
706
707#[derive(Clone)]
708struct RegisteredSelection {
709    path: PathBuf,
710    address: DocumentAddress,
711}
712
713struct RegisteredSelectionGroup {
714    documents: Vec<RegisteredSelection>,
715}
716
717fn registered_selection(document: &mant_sources::RegisteredDocument) -> RegisteredSelection {
718    RegisteredSelection {
719        path: document.path.clone(),
720        address: DocumentAddress::Markdown {
721            path: document.logical_path.clone(),
722            origin: match &document.origin {
723                RegisteredDocumentOrigin::Documents => MarkdownOrigin::Documents,
724                RegisteredDocumentOrigin::Source(name) => {
725                    MarkdownOrigin::Source { name: name.clone() }
726                }
727            },
728        },
729    }
730}
731
732struct LoadedManual {
733    document: Document,
734    address: DocumentAddress,
735}
736
737/// One explicit local document-environment snapshot.
738pub struct DocumentResolver {
739    registered: OnceLock<Result<RegisteredDocumentIndex, SourceConfigError>>,
740    manual_roots: Vec<PathBuf>,
741    manuals: OnceLock<ManualIndex>,
742    available: OnceLock<Vec<crate::catalog::AvailableDocument>>,
743}
744
745impl DocumentResolver {
746    /// Capture native manual roots and lazily snapshot the manual index and
747    /// Markdown registration.
748    #[must_use]
749    pub fn from_system() -> Self {
750        Self {
751            registered: OnceLock::new(),
752            manual_roots: discover_manual_roots(),
753            manuals: OnceLock::new(),
754            available: OnceLock::new(),
755        }
756    }
757
758    /// Validate and resolve one request against this environment snapshot.
759    ///
760    /// Reusing a resolver keeps manual and registered-document precedence
761    /// stable across related operations. Construct a new resolver to refresh
762    /// filesystem discovery.
763    ///
764    /// # Errors
765    ///
766    /// Returns [`QueryError`] for invalid input or unreadable local content.
767    pub fn resolve(
768        &self,
769        request: &QueryRequest,
770        policy: QueryPolicy,
771    ) -> Result<ResolvedContent, QueryError> {
772        validate_query_request(request, policy)?;
773        query_with(request, policy, self)
774    }
775
776    /// Resolve and materialize the request's encoded view.
777    ///
778    /// # Errors
779    ///
780    /// Returns a typed loading, projection, or search failure.
781    pub fn execute(
782        &self,
783        request: &QueryRequest,
784        policy: QueryPolicy,
785    ) -> Result<QueryViewResult, QueryExecutionError> {
786        let query = self
787            .resolve(request, policy)
788            .map_err(QueryExecutionError::Query)?;
789        project_query_view(query, &request.view)
790    }
791
792    /// Filter the same registered-document and manual snapshots used by
793    /// [`Self::resolve`].
794    ///
795    /// # Errors
796    ///
797    /// Returns source-configuration or catalog-query failures as one host
798    /// boundary diagnostic.
799    pub fn discover(&self, query: &CatalogQuery) -> Result<DocumentCatalog, String> {
800        let registered = self
801            .registered
802            .get_or_init(RegisteredDocumentIndex::load)
803            .as_ref()
804            .map_err(ToString::to_string)?;
805        let manuals = self
806            .manuals
807            .get_or_init(|| ManualIndex::from_roots(self.manual_roots.clone()));
808        let documents = self.available.get_or_init(|| {
809            crate::catalog::list_available_documents_from(
810                registered.documents().to_vec(),
811                manuals.pages(),
812            )
813        });
814        crate::catalog::query_available_documents(documents, query)
815            .map_err(|error| error.to_string())
816    }
817}
818
819impl QueryHost for DocumentResolver {
820    fn name_candidates(&self, name: &str) -> Vec<String> {
821        query_name_candidates(name)
822    }
823
824    fn locate_registered_document(
825        &self,
826        candidates: &[String],
827        source: Option<&str>,
828        phase: RegisteredLookupPhase,
829    ) -> Result<Option<RegisteredSelection>, String> {
830        let index = self
831            .registered
832            .get_or_init(RegisteredDocumentIndex::load)
833            .as_ref()
834            .map_err(ToString::to_string)?;
835        let selected = if source.is_some() {
836            index.find(candidates, source)
837        } else {
838            match phase {
839                RegisteredLookupPhase::BeforeBuiltin => index.find_before_builtin(candidates),
840                RegisteredLookupPhase::AfterBuiltin => index.find_after_builtin(candidates),
841            }
842        };
843        selected
844            .map(|registered| registered.map(registered_selection))
845            .map_err(|error| error.to_string())
846    }
847
848    fn locate_registered_document_groups(
849        &self,
850        candidates: &[String],
851        source: Option<&str>,
852        phase: RegisteredLookupPhase,
853    ) -> Result<Vec<RegisteredSelectionGroup>, String> {
854        let index = self
855            .registered
856            .get_or_init(RegisteredDocumentIndex::load)
857            .as_ref()
858            .map_err(ToString::to_string)?;
859        let groups = if let Some(source) = source {
860            index.matches_in_source(candidates, source)
861        } else {
862            Ok(match phase {
863                RegisteredLookupPhase::BeforeBuiltin => index.matches_before_builtin(candidates),
864                RegisteredLookupPhase::AfterBuiltin => index.matches_after_builtin(candidates),
865            })
866        }
867        .map_err(|error| error.to_string())?;
868        Ok(groups
869            .into_iter()
870            .map(|group| RegisteredSelectionGroup {
871                documents: group.documents.iter().map(registered_selection).collect(),
872            })
873            .collect())
874    }
875
876    fn locate_registered_address(
877        &self,
878        address: &DocumentAddress,
879    ) -> Result<Option<RegisteredSelection>, String> {
880        let DocumentAddress::Markdown { path, origin } = address else {
881            return Ok(None);
882        };
883        let origin = match origin {
884            MarkdownOrigin::Documents => RegisteredDocumentOrigin::Documents,
885            MarkdownOrigin::Source { name } => RegisteredDocumentOrigin::Source(name.clone()),
886        };
887        let index = self
888            .registered
889            .get_or_init(RegisteredDocumentIndex::load)
890            .as_ref()
891            .map_err(ToString::to_string)?;
892        index
893            .find_address(path, &origin)
894            .map(|document| {
895                document.map(|document| RegisteredSelection {
896                    path: document.path.clone(),
897                    address: address.clone(),
898                })
899            })
900            .map_err(|error| error.to_string())
901    }
902
903    fn locate_manual(&self, request: &ManualRequest) -> Result<ManualPage, String> {
904        let manuals = self
905            .manuals
906            .get_or_init(|| ManualIndex::from_roots(self.manual_roots.clone()));
907        locate_manual_source_in(request, manuals).map_err(|error| error.load_detail())
908    }
909
910    fn parse_manual(&self, page: &ManualPage) -> Result<Document, String> {
911        parse_manual_page(page).map_err(|error| error.to_string())
912    }
913
914    fn parse_manual_input(&self, path: &Path) -> Result<Document, String> {
915        parse_manual_source(path).map_err(|error| error.to_string())
916    }
917
918    fn read_tldr(&self, name: &str) -> Result<Option<TldrDocument>, String> {
919        read_cached_tldr_page(name).map_err(|error| error.to_string())
920    }
921
922    fn read_markdown(&self, path: &Path) -> Result<String, String> {
923        let file = fs::File::open(path).map_err(|error| error.to_string())?;
924        read_capped_utf8(file, MAX_MARKDOWN_BYTES)
925    }
926}
927
928/// Read at most `limit` bytes of UTF-8, rejecting anything larger.
929///
930/// The reader is bounded directly instead of trusting a reported length: a pipe
931/// or character device such as `/dev/zero` reports no size yet streams without
932/// end, so only capping the byte count keeps the read finite.
933fn read_capped_utf8(reader: impl Read, limit: u64) -> Result<String, String> {
934    read_capped_utf8_io(reader, limit).map_err(|error| error.to_string())
935}
936
937/// Read bounded UTF-8 while preserving failures from the underlying reader.
938pub(crate) fn read_capped_utf8_io(reader: impl Read, limit: u64) -> io::Result<String> {
939    crate::bounded::read_utf8(reader, limit, "Markdown document")
940}
941
942#[cfg(test)]
943mod tests;