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