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, SearchQuery,
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
27/// Upper bound on a single Markdown source, shared by every input path.
28///
29/// File and stdin readers both enforce this so an unbounded source (a pipe, a
30/// character device such as `/dev/zero`, or a pathologically large file) cannot
31/// exhaust memory. A file's reported length is not trusted: some sources report
32/// zero yet stream without end, so readers cap the byte count directly.
33pub const MAX_MARKDOWN_BYTES: u64 = 16 * 1024 * 1024;
34
35/// A query cannot produce either authoritative manual content or a quick reference.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum QueryError {
38    /// A document selector was empty after trimming.
39    EmptyName,
40    /// A native manual category was empty or malformed.
41    InvalidManualSection,
42    /// An explicit Markdown source name was empty.
43    InvalidSource,
44    /// Markdown-source and native-manual selectors were combined.
45    ConflictingSourceSelectors,
46    /// A direct Markdown input path was empty.
47    EmptyMarkdownPath,
48    /// Automatic format inference did not recognize a direct input.
49    UnsupportedInputFormat {
50        /// Caller-facing input path.
51        path: String,
52    },
53    /// Excerpt projection was requested without selectors.
54    EmptySelection,
55    /// An excerpt selector was empty.
56    EmptySelector,
57    /// An explanation entry name was empty.
58    EmptyEntry,
59    /// Search configuration failed validation.
60    InvalidSearch(SearchError),
61    /// Markdown input could not be read or parsed.
62    Markdown {
63        /// Caller-facing source path.
64        path: String,
65        /// Stable failure detail.
66        detail: String,
67    },
68    /// Markdown parsing produced neither document nor tldr content.
69    EmptyMarkdown {
70        /// Selected-document label.
71        label: String,
72    },
73    /// Registered-document discovery failed.
74    Registry {
75        /// Stable source-configuration or discovery detail.
76        detail: String,
77    },
78    /// Native manual loading failed.
79    Manual(ManualLoadError),
80    /// No full document was found, but an optional tldr entry is available.
81    ManualWithTldr {
82        /// Native-manual failure retained as the authoritative lookup error.
83        error: ManualLoadError,
84        /// Topic that can be queried explicitly with `--tldr`.
85        topic: String,
86    },
87    /// An explicit tldr query found no quick-reference candidate.
88    TldrNotFound {
89        /// Requested tldr topic.
90        topic: String,
91    },
92    /// An explicit tldr candidate could not be read or parsed.
93    Tldr {
94        /// Requested tldr topic.
95        topic: String,
96        /// Stable cache or Markdown failure detail.
97        detail: String,
98    },
99    /// No Markdown, manual, or quick-reference content could be resolved.
100    NoReadableContent {
101        /// Requested document name.
102        name: String,
103    },
104}
105
106/// Native-manual resolution or lowering failed after candidate selection.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum ManualLoadError {
109    /// No indexed native manual matched the request.
110    NotFound {
111        /// Requested manual name.
112        name: String,
113        /// Search-path and candidate detail.
114        detail: String,
115    },
116    /// A selected manual could not be parsed or lowered.
117    Parse {
118        /// Requested manual name.
119        name: String,
120        /// Stable parser or source-policy detail.
121        detail: String,
122    },
123    /// Parsing succeeded but produced no readable semantic content.
124    Empty {
125        /// Requested manual name.
126        name: String,
127        /// Physical selected manual path.
128        path: PathBuf,
129        /// Non-fatal parser findings explaining the empty result.
130        diagnostics: Vec<String>,
131    },
132}
133
134/// Materialized result of the view carried by a [`QueryRequest`].
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum QueryViewResult {
137    /// Complete resolved content with no projection.
138    Full(Box<ResolvedContent>),
139    /// Lightweight structural outline.
140    Outline(QueryOutline),
141    /// One or more selected document nodes.
142    Excerpt(QueryExcerpt),
143    /// Paginatable structure-aware search result.
144    Search(QuerySearch),
145}
146
147/// A valid request could not be loaded or projected.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum QueryExecutionError {
150    /// Input validation or document loading failure.
151    Query(QueryError),
152    /// Outline or selection projection failure.
153    Projection(ProjectionError),
154    /// Search compilation or execution failure.
155    Search(SearchError),
156}
157
158/// Closed content-resolution policy kept outside the serialized request contract.
159#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
160pub enum QueryPolicy {
161    /// Resolve a full document and attach a compatible quick reference.
162    #[default]
163    Combined,
164    /// Bypass registered Markdown and tldr content.
165    ManualOnly,
166    /// Resolve only embedded or cached tldr content through source precedence.
167    TldrOnly,
168}
169
170impl fmt::Display for QueryError {
171    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
172        match self {
173            Self::EmptyName => formatter.write_str("name must not be empty"),
174            Self::InvalidManualSection => formatter.write_str("manual section must not be empty"),
175            Self::InvalidSource => formatter.write_str("document source must not be empty"),
176            Self::ConflictingSourceSelectors => formatter.write_str(
177                "document source cannot be combined with a manual section or manual-only policy",
178            ),
179            Self::EmptyMarkdownPath => formatter.write_str("Markdown path must not be empty"),
180            Self::UnsupportedInputFormat { path } => write!(
181                formatter,
182                "could not infer the input format for '{path}'; use --input-format markdown or roff"
183            ),
184            Self::EmptySelection => formatter.write_str("at least one outline node is required"),
185            Self::EmptySelector => formatter.write_str("outline node must not be empty"),
186            Self::EmptyEntry => formatter.write_str("semantic entry must not be empty"),
187            Self::InvalidSearch(error) => error.fmt(formatter),
188            Self::Markdown { path, detail } => {
189                write!(
190                    formatter,
191                    "could not load Markdown document '{path}': {detail}"
192                )
193            }
194            Self::EmptyMarkdown { label } => {
195                write!(
196                    formatter,
197                    "Markdown document '{label}' has no readable content"
198                )
199            }
200            Self::Registry { detail } => formatter.write_str(detail),
201            Self::Manual(error) => error.fmt(formatter),
202            Self::ManualWithTldr { error, topic } => {
203                error.fmt(formatter)?;
204                write!(
205                    formatter,
206                    "\nhint: a tldr entry is available; run `mant {topic} --tldr`"
207                )
208            }
209            Self::TldrNotFound { topic } => {
210                write!(formatter, "no tldr quick reference was found for '{topic}'")
211            }
212            Self::Tldr { topic, detail } => {
213                write!(formatter, "could not load tldr entry '{topic}': {detail}")
214            }
215            Self::NoReadableContent { name } => {
216                write!(
217                    formatter,
218                    "no readable document content was found for '{name}'"
219                )
220            }
221        }
222    }
223}
224
225impl Error for QueryError {
226    fn source(&self) -> Option<&(dyn Error + 'static)> {
227        match self {
228            Self::InvalidSearch(error) => Some(error),
229            Self::Manual(error) | Self::ManualWithTldr { error, .. } => Some(error),
230            Self::EmptyName
231            | Self::InvalidManualSection
232            | Self::InvalidSource
233            | Self::ConflictingSourceSelectors
234            | Self::EmptyMarkdownPath
235            | Self::UnsupportedInputFormat { .. }
236            | Self::EmptySelection
237            | Self::EmptySelector
238            | Self::EmptyEntry
239            | Self::Markdown { .. }
240            | Self::EmptyMarkdown { .. }
241            | Self::Registry { .. }
242            | Self::TldrNotFound { .. }
243            | Self::Tldr { .. }
244            | Self::NoReadableContent { .. } => None,
245        }
246    }
247}
248
249impl fmt::Display for ManualLoadError {
250    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
251        match self {
252            Self::NotFound { name, detail } => {
253                write!(formatter, "could not load manual '{name}': {detail}")
254            }
255            Self::Parse { name, detail } => write!(
256                formatter,
257                "could not load manual '{name}': manual source: {detail}"
258            ),
259            Self::Empty {
260                name,
261                path,
262                diagnostics,
263            } => {
264                write!(
265                    formatter,
266                    "could not load manual '{name}': libmandoc parsed {} but produced no readable sections",
267                    path.display()
268                )?;
269                if !diagnostics.is_empty() {
270                    write!(formatter, "; diagnostics: {}", diagnostics.join("; "))?;
271                }
272                Ok(())
273            }
274        }
275    }
276}
277
278impl Error for ManualLoadError {}
279
280impl fmt::Display for QueryExecutionError {
281    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
282        match self {
283            Self::Query(error) => error.fmt(formatter),
284            Self::Projection(error) => error.fmt(formatter),
285            Self::Search(error) => error.fmt(formatter),
286        }
287    }
288}
289
290impl Error for QueryExecutionError {
291    fn source(&self) -> Option<&(dyn Error + 'static)> {
292        match self {
293            Self::Query(error) => Some(error),
294            Self::Projection(error) => Some(error),
295            Self::Search(error) => Some(error),
296        }
297    }
298}
299
300/// Query the local man database and optional offline tldr caches.
301///
302/// # Errors
303///
304/// Returns [`QueryError`] for invalid input or when neither source can produce
305/// readable content.
306pub fn resolve_query(request: &QueryRequest) -> Result<ResolvedContent, QueryError> {
307    resolve_query_with_policy(request, QueryPolicy::default())
308}
309
310/// Query with an explicit input-resolution policy.
311///
312/// # Errors
313///
314/// Returns [`QueryError`] under the same conditions as [`resolve_query`].
315pub fn resolve_query_with_policy(
316    request: &QueryRequest,
317    policy: QueryPolicy,
318) -> Result<ResolvedContent, QueryError> {
319    let resolver = DocumentResolver::from_system();
320    resolver.resolve(request, policy)
321}
322
323/// Load and materialize the view encoded in one native request.
324///
325/// # Errors
326///
327/// Returns a typed loading, projection, or search failure.
328pub fn execute_query(
329    request: &QueryRequest,
330    policy: QueryPolicy,
331) -> Result<QueryViewResult, QueryExecutionError> {
332    let resolver = DocumentResolver::from_system();
333    resolver.execute(request, policy)
334}
335
336/// Materialize one view from an already loaded query.
337///
338/// # Errors
339///
340/// Returns a typed projection or search failure.
341pub fn project_query_view(
342    query: ResolvedContent,
343    view: &QueryView,
344) -> Result<QueryViewResult, QueryExecutionError> {
345    match view {
346        QueryView::Full {} => Ok(QueryViewResult::Full(Box::new(query))),
347        QueryView::Outline { detail } => build_outline_with_detail(&query, *detail)
348            .map(QueryViewResult::Outline)
349            .map_err(QueryExecutionError::Projection),
350        QueryView::Excerpt { selectors } => select_excerpt(&query, selectors)
351            .map(QueryViewResult::Excerpt)
352            .map_err(QueryExecutionError::Projection),
353        QueryView::Explain { entry } => select_explanation(&query, entry)
354            .map(QueryViewResult::Excerpt)
355            .map_err(QueryExecutionError::Projection),
356        QueryView::Search {
357            pattern,
358            syntax,
359            case,
360            scope,
361            word,
362            context_lines,
363            limit,
364            offset,
365        } => search_query(
366            &query,
367            &SearchQuery {
368                pattern: pattern.clone(),
369                syntax: *syntax,
370                case: *case,
371                scope: *scope,
372                word: *word,
373                context_lines: *context_lines,
374                limit: *limit,
375                offset: *offset,
376            },
377        )
378        .map(QueryViewResult::Search)
379        .map_err(QueryExecutionError::Search),
380    }
381}
382
383/// Validate all request and policy invariants before local I/O.
384///
385/// # Errors
386///
387/// Returns the exact invalid input constraint.
388pub fn validate_query_request(
389    request: &QueryRequest,
390    policy: QueryPolicy,
391) -> Result<(), QueryError> {
392    match &request.input {
393        QueryInput::Document {
394            selector,
395            source,
396            manual_section,
397        } => {
398            if selector.trim().is_empty() {
399                return Err(QueryError::EmptyName);
400            }
401            if source
402                .as_deref()
403                .is_some_and(|value| value.trim().is_empty())
404            {
405                return Err(QueryError::InvalidSource);
406            }
407            if manual_section
408                .as_deref()
409                .is_some_and(|value| value.trim().is_empty())
410            {
411                return Err(QueryError::InvalidManualSection);
412            }
413            if source.is_some() && (manual_section.is_some() || policy == QueryPolicy::ManualOnly) {
414                return Err(QueryError::ConflictingSourceSelectors);
415            }
416        }
417        QueryInput::File { path, .. } => {
418            if path.trim().is_empty() {
419                return Err(QueryError::EmptyMarkdownPath);
420            }
421            if policy != QueryPolicy::Combined {
422                return Err(QueryError::Markdown {
423                    path: path.trim().to_owned(),
424                    detail: "content-only policies do not apply to direct input".to_owned(),
425                });
426            }
427        }
428    }
429    match &request.view {
430        QueryView::Excerpt { selectors } => {
431            if selectors.is_empty() {
432                return Err(QueryError::EmptySelection);
433            }
434            if selectors.iter().any(|selector| selector.trim().is_empty()) {
435                return Err(QueryError::EmptySelector);
436            }
437        }
438        QueryView::Explain { entry } if entry.trim().is_empty() => {
439            return Err(QueryError::EmptyEntry);
440        }
441        QueryView::Search {
442            pattern,
443            syntax,
444            case,
445            scope,
446            word,
447            context_lines,
448            limit,
449            offset,
450        } => validate_search_query(&SearchQuery {
451            pattern: pattern.clone(),
452            syntax: *syntax,
453            case: *case,
454            scope: *scope,
455            word: *word,
456            context_lines: *context_lines,
457            limit: *limit,
458            offset: *offset,
459        })
460        .map_err(QueryError::InvalidSearch)?,
461        QueryView::Full {} | QueryView::Outline { .. } | QueryView::Explain { .. } => {}
462    }
463    Ok(())
464}
465
466trait QueryHost {
467    fn name_candidates(&self, name: &str) -> Vec<String>;
468    fn locate_registered_document(
469        &self,
470        candidates: &[String],
471        source: Option<&str>,
472        phase: RegisteredLookupPhase,
473    ) -> Result<Option<RegisteredSelection>, String>;
474    fn locate_registered_document_groups(
475        &self,
476        candidates: &[String],
477        source: Option<&str>,
478        phase: RegisteredLookupPhase,
479    ) -> Result<Vec<RegisteredSelectionGroup>, String>;
480    fn locate_registered_address(
481        &self,
482        address: &DocumentAddress,
483    ) -> Result<Option<RegisteredSelection>, String>;
484    fn locate_manual(&self, request: &ManualRequest) -> Result<ManualPage, String>;
485    fn parse_manual(&self, page: &ManualPage) -> Result<Document, String>;
486    fn parse_manual_input(&self, path: &Path) -> Result<Document, String>;
487    fn read_tldr(&self, name: &str) -> Result<Option<TldrDocument>, String>;
488    fn read_markdown(&self, path: &Path) -> Result<String, String>;
489}
490
491#[derive(Clone, Copy)]
492enum RegisteredLookupPhase {
493    BeforeBuiltin,
494    AfterBuiltin,
495}
496
497#[derive(Clone)]
498struct RegisteredSelection {
499    path: PathBuf,
500    address: DocumentAddress,
501}
502
503struct RegisteredSelectionGroup {
504    documents: Vec<RegisteredSelection>,
505}
506
507fn registered_selection(document: &mant_sources::RegisteredDocument) -> RegisteredSelection {
508    RegisteredSelection {
509        path: document.path.clone(),
510        address: DocumentAddress::Markdown {
511            path: document.logical_path.clone(),
512            origin: match &document.origin {
513                RegisteredDocumentOrigin::Documents => MarkdownOrigin::Documents,
514                RegisteredDocumentOrigin::Source(name) => {
515                    MarkdownOrigin::Source { name: name.clone() }
516                }
517            },
518        },
519    }
520}
521
522struct LoadedManual {
523    document: Document,
524    address: DocumentAddress,
525}
526
527/// One explicit local document-environment snapshot.
528pub struct DocumentResolver {
529    registered: OnceLock<Result<RegisteredDocumentIndex, SourceConfigError>>,
530    manual_roots: Vec<PathBuf>,
531    manuals: OnceLock<ManualIndex>,
532    available: OnceLock<Vec<crate::catalog::AvailableDocument>>,
533}
534
535impl DocumentResolver {
536    /// Capture the native manual index and lazily snapshot Markdown registration.
537    #[must_use]
538    pub fn from_system() -> Self {
539        Self {
540            registered: OnceLock::new(),
541            manual_roots: discover_manual_roots(),
542            manuals: OnceLock::new(),
543            available: OnceLock::new(),
544        }
545    }
546
547    /// Validate and resolve one request against this environment snapshot.
548    ///
549    /// Reusing a resolver keeps manual and registered-document precedence
550    /// stable across related operations. Construct a new resolver to refresh
551    /// filesystem discovery.
552    ///
553    /// # Errors
554    ///
555    /// Returns [`QueryError`] for invalid input or unreadable local content.
556    pub fn resolve(
557        &self,
558        request: &QueryRequest,
559        policy: QueryPolicy,
560    ) -> Result<ResolvedContent, QueryError> {
561        validate_query_request(request, policy)?;
562        query_with(request, policy, self)
563    }
564
565    /// Resolve and materialize the request's encoded view.
566    ///
567    /// # Errors
568    ///
569    /// Returns a typed loading, projection, or search failure.
570    pub fn execute(
571        &self,
572        request: &QueryRequest,
573        policy: QueryPolicy,
574    ) -> Result<QueryViewResult, QueryExecutionError> {
575        let query = self
576            .resolve(request, policy)
577            .map_err(QueryExecutionError::Query)?;
578        project_query_view(query, &request.view)
579    }
580
581    /// Filter the same registered-document and manual snapshots used by
582    /// [`Self::resolve`].
583    ///
584    /// # Errors
585    ///
586    /// Returns source-configuration or catalog-query failures as one host
587    /// boundary diagnostic.
588    pub fn discover(&self, query: &CatalogQuery) -> Result<DocumentCatalog, String> {
589        let registered = self
590            .registered
591            .get_or_init(RegisteredDocumentIndex::load)
592            .as_ref()
593            .map_err(ToString::to_string)?;
594        let manuals = self
595            .manuals
596            .get_or_init(|| ManualIndex::from_roots(self.manual_roots.clone()));
597        let documents = self.available.get_or_init(|| {
598            crate::catalog::list_available_documents_from(
599                registered.documents().to_vec(),
600                manuals.pages(),
601            )
602        });
603        crate::catalog::query_available_documents(documents, query)
604            .map_err(|error| error.to_string())
605    }
606}
607
608impl QueryHost for DocumentResolver {
609    fn name_candidates(&self, name: &str) -> Vec<String> {
610        query_name_candidates(name)
611    }
612
613    fn locate_registered_document(
614        &self,
615        candidates: &[String],
616        source: Option<&str>,
617        phase: RegisteredLookupPhase,
618    ) -> Result<Option<RegisteredSelection>, String> {
619        let index = self
620            .registered
621            .get_or_init(RegisteredDocumentIndex::load)
622            .as_ref()
623            .map_err(ToString::to_string)?;
624        let selected = if source.is_some() {
625            index.find(candidates, source)
626        } else {
627            match phase {
628                RegisteredLookupPhase::BeforeBuiltin => index.find_before_builtin(candidates),
629                RegisteredLookupPhase::AfterBuiltin => index.find_after_builtin(candidates),
630            }
631        };
632        selected
633            .map(|registered| registered.map(registered_selection))
634            .map_err(|error| error.to_string())
635    }
636
637    fn locate_registered_document_groups(
638        &self,
639        candidates: &[String],
640        source: Option<&str>,
641        phase: RegisteredLookupPhase,
642    ) -> Result<Vec<RegisteredSelectionGroup>, String> {
643        let index = self
644            .registered
645            .get_or_init(RegisteredDocumentIndex::load)
646            .as_ref()
647            .map_err(ToString::to_string)?;
648        let groups = if let Some(source) = source {
649            index.matches_in_source(candidates, source)
650        } else {
651            Ok(match phase {
652                RegisteredLookupPhase::BeforeBuiltin => index.matches_before_builtin(candidates),
653                RegisteredLookupPhase::AfterBuiltin => index.matches_after_builtin(candidates),
654            })
655        }
656        .map_err(|error| error.to_string())?;
657        Ok(groups
658            .into_iter()
659            .map(|group| RegisteredSelectionGroup {
660                documents: group.documents.iter().map(registered_selection).collect(),
661            })
662            .collect())
663    }
664
665    fn locate_registered_address(
666        &self,
667        address: &DocumentAddress,
668    ) -> Result<Option<RegisteredSelection>, String> {
669        let DocumentAddress::Markdown { path, origin } = address else {
670            return Ok(None);
671        };
672        let origin = match origin {
673            MarkdownOrigin::Documents => RegisteredDocumentOrigin::Documents,
674            MarkdownOrigin::Source { name } => RegisteredDocumentOrigin::Source(name.clone()),
675        };
676        let index = self
677            .registered
678            .get_or_init(RegisteredDocumentIndex::load)
679            .as_ref()
680            .map_err(ToString::to_string)?;
681        index
682            .find_address(path, &origin)
683            .map(|document| {
684                document.map(|document| RegisteredSelection {
685                    path: document.path.clone(),
686                    address: address.clone(),
687                })
688            })
689            .map_err(|error| error.to_string())
690    }
691
692    fn locate_manual(&self, request: &ManualRequest) -> Result<ManualPage, String> {
693        let manuals = self
694            .manuals
695            .get_or_init(|| ManualIndex::from_roots(self.manual_roots.clone()));
696        locate_manual_source_in(request, manuals).map_err(|error| error.to_string())
697    }
698
699    fn parse_manual(&self, page: &ManualPage) -> Result<Document, String> {
700        parse_manual_page(page).map_err(|error| error.to_string())
701    }
702
703    fn parse_manual_input(&self, path: &Path) -> Result<Document, String> {
704        parse_manual_source(path).map_err(|error| error.to_string())
705    }
706
707    fn read_tldr(&self, name: &str) -> Result<Option<TldrDocument>, String> {
708        read_cached_tldr_page(name).map_err(|error| error.to_string())
709    }
710
711    fn read_markdown(&self, path: &Path) -> Result<String, String> {
712        let file = fs::File::open(path).map_err(|error| error.to_string())?;
713        read_capped_utf8(file, MAX_MARKDOWN_BYTES)
714    }
715}
716
717/// Read at most `limit` bytes of UTF-8, rejecting anything larger.
718///
719/// The reader is bounded directly instead of trusting a reported length: a pipe
720/// or character device such as `/dev/zero` reports no size yet streams without
721/// end, so only capping the byte count keeps the read finite.
722fn read_capped_utf8(reader: impl Read, limit: u64) -> Result<String, String> {
723    read_capped_utf8_io(reader, limit).map_err(|error| error.to_string())
724}
725
726/// Read bounded UTF-8 while preserving failures from the underlying reader.
727pub(crate) fn read_capped_utf8_io(reader: impl Read, limit: u64) -> io::Result<String> {
728    crate::bounded::read_utf8(reader, limit, "Markdown document")
729}
730
731fn query_with(
732    request: &QueryRequest,
733    policy: QueryPolicy,
734    host: &dyn QueryHost,
735) -> Result<ResolvedContent, QueryError> {
736    match &request.input {
737        QueryInput::Document {
738            selector,
739            source,
740            manual_section,
741        } => query_named_document(
742            selector,
743            source.as_deref(),
744            manual_section.as_deref(),
745            policy,
746            host,
747        ),
748        QueryInput::File { path, format } => query_input_file(path, *format, policy, host),
749    }
750}
751
752fn query_input_file(
753    requested_path: &str,
754    format: InputFormat,
755    policy: QueryPolicy,
756    host: &dyn QueryHost,
757) -> Result<ResolvedContent, QueryError> {
758    let path = requested_path.trim();
759    if path.is_empty() {
760        return Err(QueryError::EmptyMarkdownPath);
761    }
762    let format = match format {
763        InputFormat::Auto => {
764            detect_input_format(path).ok_or_else(|| QueryError::UnsupportedInputFormat {
765                path: path.to_owned(),
766            })?
767        }
768        format => format,
769    };
770    match format {
771        InputFormat::Markdown => query_markdown_file(path, policy, host),
772        InputFormat::Roff => {
773            if policy != QueryPolicy::Combined {
774                return Err(QueryError::ConflictingSourceSelectors);
775            }
776            let document = host.parse_manual_input(Path::new(path)).map_err(|detail| {
777                QueryError::Manual(ManualLoadError::Parse {
778                    name: path.to_owned(),
779                    detail,
780                })
781            })?;
782            if document.sections.is_empty() && document.blocks.is_empty() {
783                return Err(QueryError::NoReadableContent {
784                    name: path.to_owned(),
785                });
786            }
787            let label = document
788                .meta
789                .names
790                .first()
791                .cloned()
792                .or_else(|| document.meta.title.clone())
793                .unwrap_or_else(|| input_file_label(path));
794            Ok(ResolvedContent {
795                label,
796                address: None,
797                document: Some(document),
798                tldr: None,
799            })
800        }
801        InputFormat::Auto => unreachable!("auto input was resolved above"),
802    }
803}
804
805fn detect_input_format(path: &str) -> Option<InputFormat> {
806    let mut name = Path::new(path).file_name()?.to_str()?.to_ascii_lowercase();
807    let mut compressed = false;
808    if Path::new(&name)
809        .extension()
810        .and_then(OsStr::to_str)
811        .is_some_and(|extension| matches!(extension, "gz" | "zst"))
812    {
813        name = Path::new(&name).file_stem()?.to_str()?.to_owned();
814        compressed = true;
815    }
816    let extension = Path::new(&name).extension()?.to_str()?;
817    if matches!(extension, "md" | "markdown") {
818        return (!compressed).then_some(InputFormat::Markdown);
819    }
820    if matches!(extension, "roff" | "man" | "mdoc") {
821        return Some(InputFormat::Roff);
822    }
823    let section = extension;
824    let valid_section = matches!(section, "l" | "n")
825        || section
826            .chars()
827            .next()
828            .is_some_and(|first| first.is_ascii_digit())
829            && section
830                .chars()
831                .all(|character| character.is_ascii_alphanumeric());
832    valid_section.then_some(InputFormat::Roff)
833}
834
835fn input_file_label(path: &str) -> String {
836    Path::new(path)
837        .file_name()
838        .and_then(OsStr::to_str)
839        .unwrap_or(path)
840        .to_owned()
841}
842
843fn query_markdown_file(
844    requested_path: &str,
845    policy: QueryPolicy,
846    host: &dyn QueryHost,
847) -> Result<ResolvedContent, QueryError> {
848    let path = requested_path.trim();
849    if path.is_empty() {
850        return Err(QueryError::EmptyMarkdownPath);
851    }
852    if policy != QueryPolicy::Combined {
853        return Err(QueryError::Markdown {
854            path: path.to_owned(),
855            detail: "content-only policies do not apply to direct input".to_owned(),
856        });
857    }
858    let source = host
859        .read_markdown(Path::new(path))
860        .map_err(|detail| QueryError::Markdown {
861            path: path.to_owned(),
862            detail,
863        })?;
864    query_markdown_text(&source, Some(path.to_owned()))
865}
866
867/// Parse in-memory Markdown for the direct `mant -` command.
868///
869/// This helper intentionally sits outside [`QueryRequest`]: public protocol
870/// requests reference local files and never embed arbitrary document content.
871///
872/// # Errors
873///
874/// Returns [`QueryError::EmptyMarkdown`] when parsing yields no visible blocks
875/// or sections.
876pub fn query_markdown_text(
877    source: &str,
878    source_path: Option<String>,
879) -> Result<ResolvedContent, QueryError> {
880    let label = source_path.as_deref().map_or_else(
881        || "stdin".to_owned(),
882        |path| {
883            Path::new(path)
884                .file_name()
885                .and_then(OsStr::to_str)
886                .unwrap_or(path)
887                .to_owned()
888        },
889    );
890    let error_path = source_path.clone().unwrap_or_else(|| "stdin".to_owned());
891    let parsed = parse_markdown(source, source_path).map_err(|error| QueryError::Markdown {
892        path: error_path,
893        detail: error.to_string(),
894    })?;
895    let document_is_empty =
896        parsed.document.blocks.is_empty() && parsed.document.sections.is_empty();
897    if document_is_empty && parsed.tldr.is_none() {
898        return Err(QueryError::EmptyMarkdown {
899            label: label.clone(),
900        });
901    }
902    Ok(ResolvedContent {
903        address: None,
904        label,
905        document: (!document_is_empty).then_some(parsed.document),
906        tldr: parsed.tldr,
907    })
908}
909
910/// Parse one bounded roff stream without consulting MANPATH or following `.so`.
911///
912/// # Errors
913///
914/// Returns a native parse error or an empty-document error.
915pub fn query_roff_bytes(source: &[u8]) -> Result<ResolvedContent, QueryError> {
916    if u64::try_from(source.len()).unwrap_or(u64::MAX) > crate::MAX_MANUAL_BYTES {
917        return Err(QueryError::Manual(ManualLoadError::Parse {
918            name: "stdin".to_owned(),
919            detail: format!(
920                "roff input exceeds the {}-byte limit",
921                crate::MAX_MANUAL_BYTES
922            ),
923        }));
924    }
925    let document = parse_manual_bytes(Path::new("stdin"), source).map_err(|error| {
926        QueryError::Manual(ManualLoadError::Parse {
927            name: "stdin".to_owned(),
928            detail: error.to_string(),
929        })
930    })?;
931    if document.sections.is_empty() && document.blocks.is_empty() {
932        return Err(QueryError::NoReadableContent {
933            name: "stdin".to_owned(),
934        });
935    }
936    let label = document
937        .meta
938        .names
939        .first()
940        .cloned()
941        .or_else(|| document.meta.title.clone())
942        .unwrap_or_else(|| "stdin".to_owned());
943    Ok(ResolvedContent {
944        address: None,
945        label,
946        document: Some(document),
947        tldr: None,
948    })
949}
950
951fn query_named_document(
952    name: &str,
953    requested_source: Option<&str>,
954    requested_manual_section: Option<&str>,
955    policy: QueryPolicy,
956    host: &dyn QueryHost,
957) -> Result<ResolvedContent, QueryError> {
958    let name = name.trim();
959    if name.is_empty() {
960        return Err(QueryError::EmptyName);
961    }
962    if let Some(address) = parse_catalog_address(name) {
963        if requested_source.is_some()
964            || requested_manual_section.is_some()
965            || policy == QueryPolicy::ManualOnly
966        {
967            return Err(QueryError::ConflictingSourceSelectors);
968        }
969        return query_catalog_address(name, &address, policy, host);
970    }
971    let section = requested_manual_section.map(str::trim);
972    if section.is_some_and(str::is_empty) {
973        return Err(QueryError::InvalidManualSection);
974    }
975    let section = section.map(ToOwned::to_owned);
976    let source = requested_source.map(str::trim);
977    if source.is_some_and(str::is_empty) {
978        return Err(QueryError::InvalidSource);
979    }
980    if source.is_some() && (section.is_some() || policy == QueryPolicy::ManualOnly) {
981        return Err(QueryError::ConflictingSourceSelectors);
982    }
983    if policy == QueryPolicy::TldrOnly && section.is_some() {
984        return Err(QueryError::ConflictingSourceSelectors);
985    }
986    let require_manual = policy == QueryPolicy::ManualOnly || section.is_some();
987    let candidates = host.name_candidates(name);
988
989    if policy == QueryPolicy::TldrOnly {
990        return query_tldr_only(name, &candidates, source, host);
991    }
992
993    // Personal documents and positive-priority sources form the preferred
994    // registration phase. Explicit source selection always wins regardless of
995    // its configured rank. Non-positive sources are consulted only after the
996    // priority-zero native-manual phase fails.
997    if section.is_none() && policy == QueryPolicy::Combined {
998        let registered = host
999            .locate_registered_document(&candidates, source, RegisteredLookupPhase::BeforeBuiltin)
1000            .map_err(|detail| QueryError::Registry { detail })?;
1001        if let Some(registered) = registered {
1002            return query_registered_document(name, &registered, host);
1003        }
1004        if source.is_some() {
1005            return Err(QueryError::NoReadableContent {
1006                name: name.to_owned(),
1007            });
1008        }
1009    }
1010
1011    // Explicit manual selection is exclusive: --manual and --man-section must not
1012    // appear to resolve to tldr just because the quick reference is rendered
1013    // before the requested page. Unqualified queries retain tldr as an
1014    // optional augmentation and never update it during a read.
1015    let tldr = if require_manual {
1016        None
1017    } else {
1018        host.read_tldr(name).ok().flatten()
1019    };
1020    let mut manual = load_manual(name, &candidates, section.as_deref(), host);
1021
1022    // A malformed page may omit its own section metadata. Preserve the
1023    // requested section so labels stay `name(N)`.
1024    if let (Ok(manual), Some(section)) = (&mut manual, section.as_deref())
1025        && manual.document.meta.manual_section.is_none()
1026    {
1027        manual.document.meta.manual_section = Some(section.to_owned());
1028    }
1029
1030    // An explicit manual request must not degrade into an apparently
1031    // successful tldr-only response.
1032    if require_manual {
1033        return match manual {
1034            Ok(manual) => Ok(ResolvedContent {
1035                address: Some(manual.address),
1036                label: name.to_owned(),
1037                document: Some(manual.document),
1038                tldr,
1039            }),
1040            Err(error) => Err(QueryError::Manual(error)),
1041        };
1042    }
1043
1044    finish_unqualified_manual(name, &candidates, manual, tldr, host)
1045}
1046
1047fn query_catalog_address(
1048    selector: &str,
1049    address: &DocumentAddress,
1050    policy: QueryPolicy,
1051    host: &dyn QueryHost,
1052) -> Result<ResolvedContent, QueryError> {
1053    match address {
1054        DocumentAddress::Markdown { .. } if policy == QueryPolicy::TldrOnly => {
1055            let registered = host
1056                .locate_registered_address(address)
1057                .map_err(|detail| QueryError::Registry { detail })?
1058                .ok_or_else(|| QueryError::TldrNotFound {
1059                    topic: selector.to_owned(),
1060                })?;
1061            query_registered_tldr(selector, &registered, host)?.ok_or_else(|| {
1062                QueryError::TldrNotFound {
1063                    topic: selector.to_owned(),
1064                }
1065            })
1066        }
1067        DocumentAddress::Markdown { .. } => {
1068            let registered = host
1069                .locate_registered_address(address)
1070                .map_err(|detail| QueryError::Registry { detail })?
1071                .ok_or_else(|| QueryError::NoReadableContent {
1072                    name: selector.to_owned(),
1073                })?;
1074            query_registered_document(selector, &registered, host)
1075        }
1076        DocumentAddress::Manual { .. } if policy == QueryPolicy::TldrOnly => {
1077            Err(QueryError::ConflictingSourceSelectors)
1078        }
1079        DocumentAddress::Manual {
1080            name,
1081            manual_section,
1082        } => query_named_document(
1083            name,
1084            None,
1085            Some(manual_section),
1086            QueryPolicy::ManualOnly,
1087            host,
1088        ),
1089    }
1090}
1091
1092fn query_tldr_only(
1093    name: &str,
1094    candidates: &[String],
1095    source: Option<&str>,
1096    host: &dyn QueryHost,
1097) -> Result<ResolvedContent, QueryError> {
1098    let before = host
1099        .locate_registered_document_groups(candidates, source, RegisteredLookupPhase::BeforeBuiltin)
1100        .map_err(|detail| QueryError::Registry { detail })?;
1101    if let Some(tldr) = first_registered_tldr(name, before, host)? {
1102        return Ok(tldr);
1103    }
1104    if source.is_some() {
1105        return Err(QueryError::TldrNotFound {
1106            topic: name.to_owned(),
1107        });
1108    }
1109
1110    if let Some(tldr) = host.read_tldr(name).map_err(|detail| QueryError::Tldr {
1111        topic: name.to_owned(),
1112        detail,
1113    })? {
1114        return Ok(ResolvedContent {
1115            address: None,
1116            label: name.to_owned(),
1117            document: None,
1118            tldr: Some(tldr),
1119        });
1120    }
1121
1122    let after = host
1123        .locate_registered_document_groups(candidates, None, RegisteredLookupPhase::AfterBuiltin)
1124        .map_err(|detail| QueryError::Registry { detail })?;
1125    first_registered_tldr(name, after, host)?.ok_or_else(|| QueryError::TldrNotFound {
1126        topic: name.to_owned(),
1127    })
1128}
1129
1130fn first_registered_tldr(
1131    name: &str,
1132    groups: Vec<RegisteredSelectionGroup>,
1133    host: &dyn QueryHost,
1134) -> Result<Option<ResolvedContent>, QueryError> {
1135    for group in groups {
1136        let mut matches = Vec::new();
1137        for registered in group.documents {
1138            if let Some(tldr) = query_registered_tldr(name, &registered, host)? {
1139                matches.push(tldr);
1140            }
1141        }
1142        match matches.len() {
1143            0 => {}
1144            1 => return Ok(matches.pop()),
1145            _ => {
1146                let choices = matches
1147                    .iter()
1148                    .filter_map(|candidate| candidate.address.as_ref())
1149                    .map(DocumentAddress::catalog_path)
1150                    .collect::<Vec<_>>()
1151                    .join("', '");
1152                return Err(QueryError::Registry {
1153                    detail: format!(
1154                        "tldr selector '{name}' is ambiguous at one document priority: '{choices}'"
1155                    ),
1156                });
1157            }
1158        }
1159    }
1160    Ok(None)
1161}
1162
1163fn query_registered_tldr(
1164    name: &str,
1165    registered: &RegisteredSelection,
1166    host: &dyn QueryHost,
1167) -> Result<Option<ResolvedContent>, QueryError> {
1168    let resolved = query_registered_document(name, registered, host)?;
1169    let Some(tldr) = resolved.tldr else {
1170        return Ok(None);
1171    };
1172    Ok(Some(ResolvedContent {
1173        address: resolved.address,
1174        label: resolved.label,
1175        document: None,
1176        tldr: Some(tldr),
1177    }))
1178}
1179
1180fn finish_unqualified_manual(
1181    name: &str,
1182    candidates: &[String],
1183    manual: Result<LoadedManual, ManualLoadError>,
1184    tldr: Option<TldrDocument>,
1185    host: &dyn QueryHost,
1186) -> Result<ResolvedContent, QueryError> {
1187    match manual {
1188        Ok(manual) => Ok(ResolvedContent {
1189            address: Some(manual.address),
1190            label: name.to_owned(),
1191            document: Some(manual.document),
1192            tldr,
1193        }),
1194        Err(error) => {
1195            let registered = host
1196                .locate_registered_document(candidates, None, RegisteredLookupPhase::AfterBuiltin)
1197                .map_err(|detail| QueryError::Registry { detail })?;
1198            if let Some(registered) = registered {
1199                query_registered_document(name, &registered, host)
1200            } else if tldr.is_some() {
1201                Err(QueryError::ManualWithTldr {
1202                    error,
1203                    topic: name.to_owned(),
1204                })
1205            } else {
1206                Err(QueryError::Manual(error))
1207            }
1208        }
1209    }
1210}
1211
1212fn parse_catalog_address(selector: &str) -> Option<DocumentAddress> {
1213    if let Some(path) = selector.strip_prefix("documents/")
1214        && !path.is_empty()
1215    {
1216        return Some(DocumentAddress::Markdown {
1217            path: path.to_owned(),
1218            origin: MarkdownOrigin::Documents,
1219        });
1220    }
1221    if let Some(rest) = selector.strip_prefix("sources/") {
1222        let (source, path) = rest.split_once('/')?;
1223        if !source.is_empty() && !path.is_empty() {
1224            return Some(DocumentAddress::Markdown {
1225                path: path.to_owned(),
1226                origin: MarkdownOrigin::Source {
1227                    name: source.to_owned(),
1228                },
1229            });
1230        }
1231    }
1232    if let Some(rest) = selector.strip_prefix("manual/") {
1233        let (manual_section, name) = rest.split_once('/')?;
1234        if !manual_section.is_empty() && !name.is_empty() && !name.contains('/') {
1235            return Some(DocumentAddress::Manual {
1236                name: name.to_owned(),
1237                manual_section: manual_section.to_owned(),
1238            });
1239        }
1240    }
1241    None
1242}
1243
1244fn query_registered_document(
1245    name: &str,
1246    registered: &RegisteredSelection,
1247    host: &dyn QueryHost,
1248) -> Result<ResolvedContent, QueryError> {
1249    let path = &registered.path;
1250    let source_path = path.to_string_lossy().into_owned();
1251    let source = host
1252        .read_markdown(path)
1253        .map_err(|detail| QueryError::Markdown {
1254            path: source_path.clone(),
1255            detail,
1256        })?;
1257    let mut query = query_markdown_text(&source, Some(source_path))?;
1258    name.clone_into(&mut query.label);
1259    query.address = Some(registered.address.clone());
1260    Ok(query)
1261}
1262
1263fn load_manual(
1264    requested_name: &str,
1265    candidates: &[String],
1266    section: Option<&str>,
1267    host: &dyn QueryHost,
1268) -> Result<LoadedManual, ManualLoadError> {
1269    let mut first_locate_error = None;
1270    let mut located = None;
1271    for candidate in candidates {
1272        let request = ManualRequest::new(candidate, section.map(ToOwned::to_owned));
1273        match host.locate_manual(&request) {
1274            Ok(page) => {
1275                located = Some(page);
1276                break;
1277            }
1278            Err(error) => {
1279                first_locate_error.get_or_insert(error);
1280            }
1281        }
1282    }
1283    let Some(page) = located else {
1284        let error =
1285            first_locate_error.unwrap_or_else(|| "no name candidates were available".to_owned());
1286        return Err(ManualLoadError::NotFound {
1287            name: requested_name.to_owned(),
1288            detail: error,
1289        });
1290    };
1291
1292    let source_path = page.path.clone();
1293    let address = DocumentAddress::Manual {
1294        name: page.name.clone(),
1295        manual_section: page.section.clone(),
1296    };
1297    let document = host
1298        .parse_manual(&page)
1299        .map_err(|detail| ManualLoadError::Parse {
1300            name: requested_name.to_owned(),
1301            detail,
1302        })?;
1303    if document.sections.is_empty() {
1304        let diagnostics = document
1305            .diagnostics
1306            .iter()
1307            .map(|diagnostic| {
1308                let location = diagnostic.source.map_or_else(String::new, |source| {
1309                    format!(" at {}:{}", source.line, source.column)
1310                });
1311                format!("{:?}{location}: {}", diagnostic.level, diagnostic.message)
1312            })
1313            .collect::<Vec<_>>();
1314        return Err(ManualLoadError::Empty {
1315            name: requested_name.to_owned(),
1316            path: source_path,
1317            diagnostics,
1318        });
1319    }
1320    Ok(LoadedManual { document, address })
1321}
1322
1323#[cfg(test)]
1324mod tests {
1325    use std::{
1326        io,
1327        path::{Path, PathBuf},
1328        sync::Mutex,
1329    };
1330
1331    use mant_ir::{
1332        Diagnostic, DiagnosticLevel, Document, DocumentMeta, DocumentSource, Section, SourceFormat,
1333        TldrDocument, TldrOrigin,
1334    };
1335    use mant_protocol::{
1336        DocumentAddress, InputFormat, MarkdownOrigin, QueryInput, QueryRequest, QueryView,
1337        RequestSchema,
1338    };
1339    use mant_sources::BUILTIN_CONTENT_PRIORITY;
1340
1341    use crate::{ManualPage, ManualRequest};
1342
1343    use super::{
1344        MAX_MARKDOWN_BYTES, QueryError, QueryHost, QueryPolicy, RegisteredLookupPhase,
1345        RegisteredSelection, RegisteredSelectionGroup, query_markdown_text, query_with,
1346        read_capped_utf8, read_capped_utf8_io,
1347    };
1348
1349    #[derive(Clone)]
1350    struct StubHost {
1351        name_candidates: Option<Vec<String>>,
1352        registered_document: Option<PathBuf>,
1353        registered_name: Option<String>,
1354        registered_source_priority: Option<i32>,
1355        locate: Result<ManualPage, String>,
1356        manual_name: Option<String>,
1357        direct: Result<Document, String>,
1358        tldr: Result<Option<TldrDocument>, String>,
1359        markdown: Result<String, String>,
1360        calls: std::sync::Arc<Mutex<Vec<&'static str>>>,
1361    }
1362
1363    impl QueryHost for StubHost {
1364        fn name_candidates(&self, name: &str) -> Vec<String> {
1365            self.name_candidates
1366                .clone()
1367                .unwrap_or_else(|| vec![name.to_owned()])
1368        }
1369
1370        fn locate_registered_document(
1371            &self,
1372            candidates: &[String],
1373            source: Option<&str>,
1374            phase: RegisteredLookupPhase,
1375        ) -> Result<Option<RegisteredSelection>, String> {
1376            self.calls
1377                .lock()
1378                .expect("calls lock")
1379                .push(if source.is_some() {
1380                    "source"
1381                } else {
1382                    match phase {
1383                        RegisteredLookupPhase::BeforeBuiltin => "name",
1384                        RegisteredLookupPhase::AfterBuiltin => "fallback",
1385                    }
1386                });
1387            if source.is_none()
1388                && match phase {
1389                    RegisteredLookupPhase::BeforeBuiltin => self
1390                        .registered_source_priority
1391                        .is_some_and(|priority| priority <= BUILTIN_CONTENT_PRIORITY),
1392                    RegisteredLookupPhase::AfterBuiltin => self
1393                        .registered_source_priority
1394                        .is_none_or(|priority| priority > BUILTIN_CONTENT_PRIORITY),
1395                }
1396            {
1397                return Ok(None);
1398            }
1399            if self
1400                .registered_name
1401                .as_deref()
1402                .is_some_and(|registered_name| {
1403                    !candidates
1404                        .iter()
1405                        .any(|candidate| candidate == registered_name)
1406                })
1407            {
1408                return Ok(None);
1409            }
1410            Ok(self
1411                .registered_document
1412                .clone()
1413                .map(|path| RegisteredSelection {
1414                    path,
1415                    address: DocumentAddress::Markdown {
1416                        path: self
1417                            .registered_name
1418                            .clone()
1419                            .unwrap_or_else(|| candidates[0].clone()),
1420                        origin: source.map_or_else(
1421                            || {
1422                                self.registered_source_priority.map_or(
1423                                    MarkdownOrigin::Documents,
1424                                    |_| MarkdownOrigin::Source {
1425                                        name: "team".to_owned(),
1426                                    },
1427                                )
1428                            },
1429                            |name| MarkdownOrigin::Source {
1430                                name: name.to_owned(),
1431                            },
1432                        ),
1433                    },
1434                }))
1435        }
1436
1437        fn locate_registered_document_groups(
1438            &self,
1439            candidates: &[String],
1440            source: Option<&str>,
1441            phase: RegisteredLookupPhase,
1442        ) -> Result<Vec<RegisteredSelectionGroup>, String> {
1443            self.locate_registered_document(candidates, source, phase)
1444                .map(|selection| {
1445                    selection
1446                        .map(|value| {
1447                            vec![RegisteredSelectionGroup {
1448                                documents: vec![value],
1449                            }]
1450                        })
1451                        .unwrap_or_default()
1452                })
1453        }
1454
1455        fn locate_registered_address(
1456            &self,
1457            address: &DocumentAddress,
1458        ) -> Result<Option<RegisteredSelection>, String> {
1459            self.calls.lock().expect("calls lock").push("address");
1460            Ok(self
1461                .registered_document
1462                .clone()
1463                .map(|path| RegisteredSelection {
1464                    path,
1465                    address: address.clone(),
1466                }))
1467        }
1468
1469        fn locate_manual(&self, request: &ManualRequest) -> Result<ManualPage, String> {
1470            self.calls.lock().expect("calls lock").push("locate");
1471            if self
1472                .manual_name
1473                .as_deref()
1474                .is_some_and(|manual_name| manual_name != request.name)
1475            {
1476                return Err("source not found".to_owned());
1477            }
1478            self.locate.clone()
1479        }
1480
1481        fn parse_manual(&self, _page: &ManualPage) -> Result<Document, String> {
1482            self.calls.lock().expect("calls lock").push("parse");
1483            self.direct.clone()
1484        }
1485
1486        fn parse_manual_input(&self, _path: &Path) -> Result<Document, String> {
1487            self.calls.lock().expect("calls lock").push("manual-input");
1488            self.direct.clone()
1489        }
1490
1491        fn read_tldr(&self, _name: &str) -> Result<Option<TldrDocument>, String> {
1492            self.calls.lock().expect("calls lock").push("tldr");
1493            self.tldr.clone()
1494        }
1495
1496        fn read_markdown(&self, _path: &Path) -> Result<String, String> {
1497            self.calls.lock().expect("calls lock").push("markdown");
1498            self.markdown.clone()
1499        }
1500    }
1501
1502    fn document(format: SourceFormat, unsupported: bool, readable: bool) -> Document {
1503        Document {
1504            parser: None,
1505            source: DocumentSource { format, path: None },
1506            meta: DocumentMeta::default(),
1507            diagnostics: unsupported
1508                .then_some(Diagnostic {
1509                    level: DiagnosticLevel::Unsupported,
1510                    code: None,
1511                    message: "unsupported request".to_owned(),
1512                    source: None,
1513                })
1514                .into_iter()
1515                .collect(),
1516            blocks: Vec::new(),
1517            sections: readable
1518                .then_some(Section {
1519                    id: "name-1".to_owned().into(),
1520                    title: "NAME".to_owned(),
1521                    spacing_before_lines: 0,
1522                    blocks: Vec::new(),
1523                    children: Vec::new(),
1524                    source: None,
1525                })
1526                .into_iter()
1527                .collect(),
1528        }
1529    }
1530
1531    fn tldr() -> TldrDocument {
1532        TldrDocument {
1533            title: "tool".to_owned(),
1534            description: vec!["quick reference".to_owned()],
1535            more_information: None,
1536            examples: Vec::new(),
1537            platform: "common".to_owned(),
1538            language: "en".to_owned(),
1539            source_path: "/cache/pages/common/tool.md".to_owned(),
1540            origin: TldrOrigin::TldrPages,
1541        }
1542    }
1543
1544    fn embedded_tldr_markdown() -> String {
1545        "\
1546<!-- mant:tldr:start -->
1547# tool
1548
1549> Source-owned quick reference.
1550
1551- Run the tool:
1552
1553`tool`
1554<!-- mant:tldr:end -->
1555
1556# Tool
1557
1558Full documentation.
1559"
1560        .to_owned()
1561    }
1562
1563    fn host(direct: Result<Document, String>) -> StubHost {
1564        StubHost {
1565            name_candidates: None,
1566            registered_document: None,
1567            registered_name: None,
1568            registered_source_priority: None,
1569            locate: Ok(ManualPage {
1570                name: "tool".to_owned(),
1571                section: "1".to_owned(),
1572                path: PathBuf::from("/man/tool.1"),
1573                manual_root: PathBuf::from("/man"),
1574            }),
1575            manual_name: None,
1576            direct,
1577            tldr: Ok(None),
1578            markdown: Err("Markdown unavailable".to_owned()),
1579            calls: std::sync::Arc::default(),
1580        }
1581    }
1582
1583    fn request() -> QueryRequest {
1584        QueryRequest {
1585            schema: RequestSchema::V7,
1586            input: QueryInput::Document {
1587                selector: " tool ".to_owned(),
1588                source: None,
1589                manual_section: None,
1590            },
1591            view: QueryView::Full {},
1592        }
1593    }
1594
1595    #[test]
1596    fn ordinary_manual_uses_the_native_parser() {
1597        let host = host(Ok(document(SourceFormat::Man, false, true)));
1598        let result = query_with(&request(), QueryPolicy::default(), &host).expect("query");
1599
1600        assert_eq!(result.label, "tool");
1601        assert_eq!(
1602            result.document.expect("manual").source.format,
1603            SourceFormat::Man
1604        );
1605        assert_eq!(
1606            *host.calls.lock().expect("calls lock"),
1607            ["name", "tldr", "locate", "parse"]
1608        );
1609    }
1610
1611    #[test]
1612    fn requested_manual_section_backfills_metadata_the_parser_left_empty() {
1613        let mut host = host(Ok(document(SourceFormat::Man, false, true)));
1614        host.locate.as_mut().expect("manual page").section = "3".to_owned();
1615        host.tldr = Ok(Some(tldr()));
1616        let request = QueryRequest {
1617            schema: RequestSchema::V7,
1618            input: QueryInput::Document {
1619                selector: "tool".to_owned(),
1620                source: None,
1621                manual_section: Some("3".to_owned()),
1622            },
1623            view: QueryView::Full {},
1624        };
1625
1626        let result = query_with(&request, QueryPolicy::default(), &host).expect("query");
1627        assert_eq!(
1628            result.address,
1629            Some(DocumentAddress::Manual {
1630                name: "tool".to_owned(),
1631                manual_section: "3".to_owned(),
1632            })
1633        );
1634        assert_eq!(
1635            result
1636                .document
1637                .as_ref()
1638                .expect("manual")
1639                .meta
1640                .manual_section
1641                .as_deref(),
1642            Some("3"),
1643            "requested section must label output when the parser omits it"
1644        );
1645        assert!(result.tldr.is_none(), "an explicit section is manual-only");
1646        assert_eq!(
1647            *host.calls.lock().expect("calls lock"),
1648            ["locate", "parse"],
1649            "an explicit manual section bypasses Markdown and tldr"
1650        );
1651    }
1652
1653    #[test]
1654    fn explicit_source_reads_only_registered_markdown() {
1655        let mut host = host(Err("manual must not be read".to_owned()));
1656        host.registered_document = Some(PathBuf::from("/documents/tool.md"));
1657        host.markdown = Ok("# Tool\n\nSource body.\n".to_owned());
1658        let request = QueryRequest {
1659            schema: RequestSchema::V7,
1660            input: QueryInput::Document {
1661                selector: "tool".to_owned(),
1662                source: Some("team".to_owned()),
1663                manual_section: None,
1664            },
1665            view: QueryView::Full {},
1666        };
1667        let result = query_with(&request, QueryPolicy::default(), &host).expect("source query");
1668        assert_eq!(
1669            result.address,
1670            Some(DocumentAddress::Markdown {
1671                path: "tool".to_owned(),
1672                origin: MarkdownOrigin::Source {
1673                    name: "team".to_owned(),
1674                },
1675            })
1676        );
1677        assert_eq!(
1678            result.document.expect("Markdown").meta.title.as_deref(),
1679            Some("Tool")
1680        );
1681        assert_eq!(
1682            *host.calls.lock().expect("calls lock"),
1683            ["source", "markdown"]
1684        );
1685    }
1686
1687    #[test]
1688    fn canonical_catalog_paths_resolve_exact_addresses() {
1689        let mut markdown = host(Err("manual must not be read".to_owned()));
1690        markdown.registered_document = Some(PathBuf::from("/documents/en/tool.md"));
1691        markdown.markdown = Ok("# Tool\n\nBody.\n".to_owned());
1692        let request = QueryRequest {
1693            schema: RequestSchema::V7,
1694            input: QueryInput::Document {
1695                selector: "documents/en/tool".to_owned(),
1696                source: None,
1697                manual_section: None,
1698            },
1699            view: QueryView::Full {},
1700        };
1701        let result = query_with(&request, QueryPolicy::default(), &markdown).expect("canonical");
1702        assert_eq!(
1703            result.address,
1704            Some(DocumentAddress::Markdown {
1705                path: "en/tool".to_owned(),
1706                origin: MarkdownOrigin::Documents,
1707            })
1708        );
1709        assert_eq!(
1710            *markdown.calls.lock().expect("calls"),
1711            ["address", "markdown"]
1712        );
1713
1714        let manual = host(Ok(document(SourceFormat::Man, false, true)));
1715        let request = QueryRequest {
1716            schema: RequestSchema::V7,
1717            input: QueryInput::Document {
1718                selector: "manual/1/tool".to_owned(),
1719                source: None,
1720                manual_section: None,
1721            },
1722            view: QueryView::Full {},
1723        };
1724        let result = query_with(&request, QueryPolicy::default(), &manual).expect("manual path");
1725        assert_eq!(
1726            result.address,
1727            Some(DocumentAddress::Manual {
1728                name: "tool".to_owned(),
1729                manual_section: "1".to_owned(),
1730            })
1731        );
1732        assert_eq!(*manual.calls.lock().expect("calls"), ["locate", "parse"]);
1733    }
1734
1735    #[test]
1736    fn complete_direct_document_survives_an_unsupported_finding() {
1737        let host = host(Ok(document(SourceFormat::Man, true, true)));
1738        let result = query_with(&request(), QueryPolicy::default(), &host).expect("query");
1739
1740        assert_eq!(
1741            result.document.expect("manual").source.format,
1742            SourceFormat::Man
1743        );
1744        assert_eq!(
1745            *host.calls.lock().expect("calls lock"),
1746            ["name", "tldr", "locate", "parse"]
1747        );
1748    }
1749
1750    #[test]
1751    fn manual_only_bypasses_registered_markdown() {
1752        let mut host = host(Ok(document(SourceFormat::Man, true, true)));
1753        host.registered_document = Some(PathBuf::from("/data/mant/tool.md"));
1754        host.markdown = Ok("# Registered".to_owned());
1755        host.tldr = Ok(Some(tldr()));
1756        let result =
1757            query_with(&request(), QueryPolicy::ManualOnly, &host).expect("manual-only query");
1758
1759        assert_eq!(
1760            result.document.as_ref().expect("manual").source.format,
1761            SourceFormat::Man
1762        );
1763        assert!(result.tldr.is_none(), "manual-only must not attach tldr");
1764        assert_eq!(
1765            *host.calls.lock().expect("calls lock"),
1766            ["locate", "parse"],
1767            "manual-only lookup must not inspect Markdown or tldr namespaces"
1768        );
1769    }
1770
1771    #[test]
1772    fn manual_only_failure_is_not_hidden_by_tldr() {
1773        let mut host = host(Ok(document(SourceFormat::Man, true, false)));
1774        host.tldr = Ok(Some(tldr()));
1775
1776        let error = query_with(&request(), QueryPolicy::ManualOnly, &host)
1777            .expect_err("an optional tldr page must not hide native parser failure");
1778
1779        let QueryError::Manual(detail) = error else {
1780            panic!("expected the native parser diagnostic");
1781        };
1782        assert!(detail.to_string().contains("/man/tool.1"));
1783        assert!(
1784            detail
1785                .to_string()
1786                .contains("Unsupported: unsupported request")
1787        );
1788        assert_eq!(*host.calls.lock().expect("calls lock"), ["locate", "parse"]);
1789    }
1790
1791    #[test]
1792    fn requested_manual_section_failure_is_not_hidden_by_tldr() {
1793        let mut host = host(Err("libmandoc failed".to_owned()));
1794        host.locate = Err("section not found".to_owned());
1795        host.tldr = Ok(Some(tldr()));
1796        let request = QueryRequest {
1797            schema: RequestSchema::V7,
1798            input: QueryInput::Document {
1799                selector: "tool".to_owned(),
1800                source: None,
1801                manual_section: Some("7".to_owned()),
1802            },
1803            view: QueryView::Full {},
1804        };
1805
1806        let error = query_with(&request, QueryPolicy::default(), &host)
1807            .expect_err("an explicit section must require a native manual");
1808
1809        assert!(matches!(&error, QueryError::Manual(_)));
1810        assert!(error.to_string().contains("section not found"));
1811        assert_eq!(*host.calls.lock().expect("calls lock"), ["locate"]);
1812    }
1813
1814    #[test]
1815    fn truncated_unsupported_document_is_an_error_by_default() {
1816        let host = host(Ok(document(SourceFormat::Man, true, false)));
1817
1818        let QueryError::Manual(detail) = query_with(&request(), QueryPolicy::default(), &host)
1819            .expect_err("empty-section document must error by default")
1820        else {
1821            panic!("expected Manual error");
1822        };
1823        assert!(detail.to_string().contains("produced no readable sections"));
1824    }
1825
1826    #[test]
1827    fn readable_best_effort_document_survives_parser_findings() {
1828        let host = host(Ok(document(SourceFormat::Mdoc, true, true)));
1829        let result = query_with(&request(), QueryPolicy::default(), &host).expect("query");
1830        assert_eq!(
1831            result.document.expect("manual").source.format,
1832            SourceFormat::Mdoc
1833        );
1834    }
1835
1836    #[test]
1837    fn ordinary_query_reports_a_tldr_hint_after_total_document_failure() {
1838        let mut host = host(Err("libmandoc failed".to_owned()));
1839        host.locate = Err("source not found".to_owned());
1840        host.tldr = Ok(Some(tldr()));
1841        let error = query_with(&request(), QueryPolicy::default(), &host)
1842            .expect_err("ordinary query must require a full document");
1843
1844        assert!(matches!(error, QueryError::ManualWithTldr { .. }));
1845        assert_eq!(
1846            error.to_string(),
1847            "could not load manual 'tool': source not found\nhint: a tldr entry is available; run `mant tool --tldr`"
1848        );
1849    }
1850
1851    #[test]
1852    fn explicit_tldr_policy_survives_total_manual_failure() {
1853        let mut host = host(Err("libmandoc failed".to_owned()));
1854        host.locate = Err("source not found".to_owned());
1855        host.tldr = Ok(Some(tldr()));
1856        let result =
1857            query_with(&request(), QueryPolicy::TldrOnly, &host).expect("explicit tldr-only query");
1858
1859        assert!(result.document.is_none());
1860        assert_eq!(result.tldr.expect("tldr").title, "tool");
1861    }
1862
1863    #[test]
1864    fn positive_source_embedded_tldr_precedes_the_builtin_cache() {
1865        let mut host = host(Err("manual must not be read".to_owned()));
1866        host.registered_document = Some(PathBuf::from("/sources/team/tool.md"));
1867        host.registered_source_priority = Some(1);
1868        host.markdown = Ok(embedded_tldr_markdown());
1869        host.tldr = Ok(Some(tldr()));
1870
1871        let result = query_with(&request(), QueryPolicy::TldrOnly, &host)
1872            .expect("positive-priority embedded tldr");
1873
1874        assert_eq!(result.tldr.expect("tldr").origin, TldrOrigin::Embedded);
1875        assert_eq!(
1876            result.address,
1877            Some(DocumentAddress::Markdown {
1878                path: "tool".to_owned(),
1879                origin: MarkdownOrigin::Source {
1880                    name: "team".to_owned(),
1881                },
1882            })
1883        );
1884        assert_eq!(*host.calls.lock().expect("calls"), ["name", "markdown"]);
1885    }
1886
1887    #[test]
1888    fn builtin_tldr_cache_wins_a_zero_priority_tie() {
1889        let mut host = host(Err("manual must not be read".to_owned()));
1890        host.registered_document = Some(PathBuf::from("/sources/team/tool.md"));
1891        host.registered_source_priority = Some(0);
1892        host.markdown = Ok(embedded_tldr_markdown());
1893        host.tldr = Ok(Some(tldr()));
1894
1895        let result =
1896            query_with(&request(), QueryPolicy::TldrOnly, &host).expect("builtin tldr cache");
1897
1898        assert_eq!(result.tldr.expect("tldr").origin, TldrOrigin::TldrPages);
1899        assert_eq!(*host.calls.lock().expect("calls"), ["name", "tldr"]);
1900    }
1901
1902    #[test]
1903    fn tldr_lookup_skips_markdown_without_an_embedded_quick_reference() {
1904        let mut host = host(Err("manual must not be read".to_owned()));
1905        host.registered_document = Some(PathBuf::from("/sources/team/tool.md"));
1906        host.registered_source_priority = Some(10);
1907        host.markdown = Ok("# Tool\n\nFull documentation only.\n".to_owned());
1908        host.tldr = Ok(Some(tldr()));
1909
1910        let result = query_with(&request(), QueryPolicy::TldrOnly, &host)
1911            .expect("cached tldr after empty Markdown candidate");
1912
1913        assert_eq!(result.tldr.expect("tldr").origin, TldrOrigin::TldrPages);
1914        assert_eq!(
1915            *host.calls.lock().expect("calls"),
1916            ["name", "markdown", "tldr"]
1917        );
1918    }
1919
1920    #[test]
1921    fn negative_source_embedded_tldr_is_the_final_fallback() {
1922        let mut host = host(Err("manual must not be read".to_owned()));
1923        host.registered_document = Some(PathBuf::from("/sources/team/tool.md"));
1924        host.registered_source_priority = Some(-1);
1925        host.markdown = Ok(embedded_tldr_markdown());
1926
1927        let result = query_with(&request(), QueryPolicy::TldrOnly, &host)
1928            .expect("negative-priority embedded tldr");
1929
1930        assert_eq!(result.tldr.expect("tldr").origin, TldrOrigin::Embedded);
1931        assert_eq!(
1932            *host.calls.lock().expect("calls"),
1933            ["name", "tldr", "fallback", "markdown"]
1934        );
1935    }
1936
1937    #[test]
1938    fn explicit_source_limits_tldr_lookup_to_that_source() {
1939        let mut host = host(Err("manual must not be read".to_owned()));
1940        host.registered_document = Some(PathBuf::from("/sources/team/tool.md"));
1941        host.registered_source_priority = Some(-1);
1942        host.markdown = Ok(embedded_tldr_markdown());
1943        host.tldr = Ok(Some(tldr()));
1944        let mut request = request();
1945        let QueryInput::Document { source, .. } = &mut request.input else {
1946            unreachable!("document request")
1947        };
1948        *source = Some("team".to_owned());
1949
1950        let result =
1951            query_with(&request, QueryPolicy::TldrOnly, &host).expect("source-owned embedded tldr");
1952
1953        assert_eq!(result.tldr.expect("tldr").origin, TldrOrigin::Embedded);
1954        assert_eq!(*host.calls.lock().expect("calls"), ["source", "markdown"]);
1955    }
1956
1957    #[test]
1958    fn reports_both_manual_paths_when_no_content_exists() {
1959        let mut host = host(Err("libmandoc failed".to_owned()));
1960        host.locate = Err("source not found".to_owned());
1961        let error = query_with(&request(), QueryPolicy::default(), &host)
1962            .expect_err("empty query must fail");
1963        assert_eq!(
1964            error.to_string(),
1965            "could not load manual 'tool': source not found"
1966        );
1967    }
1968
1969    #[test]
1970    fn validates_before_touching_host_state() {
1971        let host = host(Ok(document(SourceFormat::Man, false, true)));
1972        assert_eq!(
1973            query_with(
1974                &QueryRequest {
1975                    schema: RequestSchema::V7,
1976                    input: QueryInput::Document {
1977                        selector: " ".to_owned(),
1978                        source: None,
1979                        manual_section: None,
1980                    },
1981                    view: QueryView::Full {},
1982                },
1983                QueryPolicy::default(),
1984                &host
1985            ),
1986            Err(QueryError::EmptyName)
1987        );
1988        assert!(host.calls.lock().expect("calls lock").is_empty());
1989    }
1990
1991    #[test]
1992    fn registered_markdown_shadows_an_unqualified_manual_name() {
1993        let mut host = host(Err("manual parser must not run".to_owned()));
1994        host.registered_document = Some(PathBuf::from("/data/mant/tool.md"));
1995        host.markdown = Ok("# Tool\n\n## Options\n\n- `--help`: Show help.\n".to_owned());
1996
1997        let result = query_with(&request(), QueryPolicy::default(), &host)
1998            .expect("registered Markdown name");
1999
2000        assert_eq!(result.label, "tool");
2001        assert!(result.tldr.is_none());
2002        let document = result.document.expect("registered document");
2003        assert_eq!(document.source.format, SourceFormat::Markdown);
2004        assert_eq!(document.source.path.as_deref(), Some("/data/mant/tool.md"));
2005        assert_eq!(
2006            *host.calls.lock().expect("calls lock"),
2007            ["name", "markdown"],
2008            "a registered name must not consult man or external tldr caches"
2009        );
2010    }
2011
2012    #[test]
2013    fn positive_source_priority_shadows_a_native_manual() {
2014        let mut host = host(Err("manual parser must not run".to_owned()));
2015        host.registered_document = Some(PathBuf::from("/sources/team/tool.md"));
2016        host.registered_source_priority = Some(1);
2017        host.markdown = Ok("# Team tool\n\nConfigured documentation.\n".to_owned());
2018
2019        let result = query_with(&request(), QueryPolicy::default(), &host)
2020            .expect("positive-priority Markdown");
2021
2022        assert_eq!(
2023            result.document.expect("document").source.format,
2024            SourceFormat::Markdown
2025        );
2026        assert_eq!(*host.calls.lock().expect("calls"), ["name", "markdown"]);
2027    }
2028
2029    #[test]
2030    fn native_manual_wins_a_zero_priority_tie() {
2031        let mut host = host(Ok(document(SourceFormat::Man, false, true)));
2032        host.registered_document = Some(PathBuf::from("/sources/team/tool.md"));
2033        host.registered_source_priority = Some(0);
2034        host.markdown = Ok("# Team tool\n\nConfigured documentation.\n".to_owned());
2035
2036        let result = query_with(&request(), QueryPolicy::default(), &host).expect("native manual");
2037
2038        assert_eq!(
2039            result.document.expect("document").source.format,
2040            SourceFormat::Man
2041        );
2042        assert_eq!(
2043            *host.calls.lock().expect("calls"),
2044            ["name", "tldr", "locate", "parse"]
2045        );
2046    }
2047
2048    #[test]
2049    fn non_positive_source_priority_falls_back_when_the_manual_is_unavailable() {
2050        let mut host = host(Err("manual parser must not run".to_owned()));
2051        host.locate = Err("source not found".to_owned());
2052        host.registered_document = Some(PathBuf::from("/sources/team/tool.md"));
2053        host.registered_source_priority = Some(-1);
2054        host.markdown = Ok("# Team tool\n\nConfigured documentation.\n".to_owned());
2055
2056        let result =
2057            query_with(&request(), QueryPolicy::default(), &host).expect("Markdown fallback");
2058
2059        assert_eq!(
2060            result.document.expect("document").source.format,
2061            SourceFormat::Markdown
2062        );
2063        assert_eq!(
2064            *host.calls.lock().expect("calls"),
2065            ["name", "tldr", "locate", "fallback", "markdown"]
2066        );
2067    }
2068
2069    #[test]
2070    fn windows_suffix_fallback_can_resolve_registered_markdown() {
2071        let mut host = host(Err("manual parser must not run".to_owned()));
2072        host.name_candidates = Some(vec!["tool".to_owned(), "tool.EXE".to_owned()]);
2073        host.registered_name = Some("tool.EXE".to_owned());
2074        host.registered_document = Some(PathBuf::from("/data/mant/tool.exe.md"));
2075        host.markdown = Ok("# Tool executable\n\nWindows command documentation.\n".to_owned());
2076
2077        let result = query_with(&request(), QueryPolicy::default(), &host)
2078            .expect("registered executable document");
2079
2080        assert_eq!(result.label, "tool");
2081        assert_eq!(
2082            result.document.expect("document").source.path.as_deref(),
2083            Some("/data/mant/tool.exe.md")
2084        );
2085        assert_eq!(
2086            *host.calls.lock().expect("calls lock"),
2087            ["name", "markdown"]
2088        );
2089    }
2090
2091    #[test]
2092    fn windows_suffix_fallback_can_resolve_a_native_manual() {
2093        let mut host = host(Ok(document(SourceFormat::Man, false, true)));
2094        host.name_candidates = Some(vec!["tool".to_owned(), "tool.EXE".to_owned()]);
2095        host.manual_name = Some("tool.EXE".to_owned());
2096        host.locate = Ok(ManualPage {
2097            name: "tool.exe".to_owned(),
2098            section: "1".to_owned(),
2099            path: PathBuf::from("/man/tool.exe.1"),
2100            manual_root: PathBuf::from("/man"),
2101        });
2102
2103        let result = query_with(&request(), QueryPolicy::default(), &host)
2104            .expect("native executable manual");
2105
2106        assert_eq!(result.label, "tool");
2107        assert_eq!(
2108            result.document.expect("manual").source.format,
2109            SourceFormat::Man
2110        );
2111        assert_eq!(
2112            *host.calls.lock().expect("calls lock"),
2113            ["name", "tldr", "locate", "locate", "parse"]
2114        );
2115    }
2116
2117    #[test]
2118    fn exact_names_win_before_windows_suffix_fallback() {
2119        let mut host = host(Err("manual parser must not run".to_owned()));
2120        host.name_candidates = Some(vec!["tool".to_owned(), "tool.EXE".to_owned()]);
2121        host.registered_document = Some(PathBuf::from("/data/mant/tool.md"));
2122        host.markdown = Ok("# Exact tool\n\nExact-name documentation.\n".to_owned());
2123
2124        let result = query_with(&request(), QueryPolicy::default(), &host)
2125            .expect("exact registered document");
2126
2127        assert_eq!(
2128            result.document.expect("document").source.path.as_deref(),
2129            Some("/data/mant/tool.md")
2130        );
2131        assert_eq!(
2132            *host.calls.lock().expect("calls lock"),
2133            ["name", "markdown"]
2134        );
2135    }
2136
2137    #[test]
2138    fn markdown_files_bypass_manual_and_tldr_sources() {
2139        let mut host = host(Err("manual parser must not run".to_owned()));
2140        host.markdown = Ok("# Tool\n\n## Options\n\n- `--help`: Show help.\n".to_owned());
2141        let result = query_with(
2142            &QueryRequest {
2143                schema: RequestSchema::V7,
2144                input: QueryInput::File {
2145                    path: "docs/tool.md".to_owned(),
2146                    format: InputFormat::Markdown,
2147                },
2148                view: QueryView::Full {},
2149            },
2150            QueryPolicy::default(),
2151            &host,
2152        )
2153        .expect("Markdown query");
2154
2155        assert_eq!(result.label, "tool.md");
2156        assert!(result.tldr.is_none());
2157        let document = result.document.expect("document");
2158        assert_eq!(document.source.format, SourceFormat::Markdown);
2159        assert_eq!(document.source.path.as_deref(), Some("docs/tool.md"));
2160        assert_eq!(
2161            *host.calls.lock().expect("calls lock"),
2162            ["markdown"],
2163            "Markdown must not consult man or tldr"
2164        );
2165    }
2166
2167    #[test]
2168    fn in_memory_markdown_is_available_without_a_protocol_content_field() {
2169        let result = query_markdown_text("# Piped\n\nBody.\n", None).expect("stdin Markdown query");
2170
2171        assert_eq!(result.label, "stdin");
2172        assert!(result.tldr.is_none());
2173        let document = result.document.expect("document");
2174        assert_eq!(document.meta.title.as_deref(), Some("Piped"));
2175        assert_eq!(document.source.path, None);
2176    }
2177
2178    #[test]
2179    fn leading_tldr_directives_are_independent_from_the_markdown_document() {
2180        let source = "\
2181<!-- mant:tldr:start -->
2182# demo
2183
2184> Concise embedded help.
2185
2186- Run the demo:
2187
2188`demo {{path}}`
2189<!-- mant:tldr:end -->
2190
2191# Demo
2192
2193Document overview.
2194
2195## Options
2196
2197- `--help`: Show help.
2198";
2199        let result =
2200            query_markdown_text(source, Some("docs/demo.md".to_owned())).expect("Markdown query");
2201
2202        let tldr = result.tldr.expect("embedded tldr");
2203        assert_eq!(tldr.title, "demo");
2204        assert_eq!(tldr.origin, TldrOrigin::Embedded);
2205        assert_eq!(tldr.source_path, "docs/demo.md");
2206        assert_eq!(tldr.examples[0].command, "demo {{path}}");
2207
2208        let document = result.document.expect("document body");
2209        assert_eq!(document.meta.title.as_deref(), Some("Demo"));
2210        assert_eq!(document.sections[0].title, "Options");
2211        assert!(
2212            document
2213                .blocks
2214                .iter()
2215                .any(|block| matches!(block, mant_ir::Block::Paragraph { .. }))
2216        );
2217        assert!(
2218            document
2219                .diagnostics
2220                .iter()
2221                .all(|diagnostic| !diagnostic.message.contains("mant:tldr"))
2222        );
2223    }
2224
2225    #[test]
2226    fn malformed_leading_tldr_directives_report_the_source_path() {
2227        let error = query_markdown_text(
2228            "<!-- mant:tldr:start -->\n# demo\n\n- Run:\n\n`demo`\n",
2229            Some("docs/broken.md".to_owned()),
2230        )
2231        .expect_err("unterminated directive");
2232
2233        assert_eq!(
2234            error.to_string(),
2235            "could not load Markdown document 'docs/broken.md': top-level <!-- mant:tldr:start --> marker is missing its <!-- mant:tldr:end --> marker"
2236        );
2237    }
2238
2239    #[test]
2240    fn capped_read_accepts_input_up_to_the_limit() {
2241        let source = "abcd";
2242        assert_eq!(
2243            read_capped_utf8(source.as_bytes(), source.len() as u64).expect("within limit"),
2244            source
2245        );
2246    }
2247
2248    #[test]
2249    fn capped_read_rejects_input_past_the_limit_without_buffering_it_whole() {
2250        // An unbounded stream (modelled by io::repeat) must fail fast on the
2251        // limit rather than read forever, matching the /dev/zero guard.
2252        let error = read_capped_utf8(io::repeat(b'a'), 8).expect_err("over limit");
2253        assert!(error.contains("exceeds the 8-byte limit"), "{error}");
2254    }
2255
2256    #[test]
2257    fn capped_read_rejects_non_utf8_input() {
2258        let error =
2259            read_capped_utf8(&[0xff, 0xfe][..], MAX_MARKDOWN_BYTES).expect_err("invalid UTF-8");
2260        assert!(error.contains("must be UTF-8"), "{error}");
2261    }
2262
2263    #[test]
2264    fn capped_io_read_preserves_the_underlying_error_kind() {
2265        struct PermissionDeniedReader;
2266
2267        impl io::Read for PermissionDeniedReader {
2268            fn read(&mut self, _buffer: &mut [u8]) -> io::Result<usize> {
2269                Err(io::Error::new(
2270                    io::ErrorKind::PermissionDenied,
2271                    "reader denied access",
2272                ))
2273            }
2274        }
2275
2276        let error = read_capped_utf8_io(PermissionDeniedReader, MAX_MARKDOWN_BYTES)
2277            .expect_err("reader failure is preserved");
2278        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
2279        assert_eq!(error.to_string(), "reader denied access");
2280    }
2281}