Skip to main content

mant_core/
query.rs

1//! Composes local manuals and cached tldr content into one versioned 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_ast::{
13    MantDocument, QueryBundle, QueryExcerpt, QueryInput, QueryOutline, QueryRequest, QuerySchema,
14    QuerySearch, QueryView, SearchQuery, TldrDocument,
15};
16use mant_sources::{RegisteredDocumentIndex, SourceConfigError};
17
18use crate::{
19    ManualIndex, ManualPage, ManualRequest, ProjectionError, SearchError,
20    build_outline_with_detail, discover_manual_roots, executable::query_name_candidates,
21    locate_manual_source_in, parse_manual_page, parse_markdown, read_cached_tldr_page,
22    search_query, select_excerpt, select_explanation, validate_search_query,
23};
24
25/// Upper bound on a single Markdown source, shared by every input path.
26///
27/// File and stdin readers both enforce this so an unbounded source (a pipe, a
28/// character device such as `/dev/zero`, or a pathologically large file) cannot
29/// exhaust memory. A file's reported length is not trusted: some sources report
30/// zero yet stream without end, so readers cap the byte count directly.
31pub const MAX_MARKDOWN_BYTES: u64 = 16 * 1024 * 1024;
32
33/// A query cannot produce either authoritative manual content or a quick reference.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum QueryError {
36    EmptyName,
37    InvalidSection,
38    InvalidSource,
39    ConflictingSourceSelectors,
40    EmptyMarkdownPath,
41    EmptySelection,
42    EmptySelector,
43    EmptyEntry,
44    InvalidSearch(SearchError),
45    Markdown { path: String, detail: String },
46    EmptyMarkdown { label: String },
47    Registry { detail: String },
48    Manual(ManualLoadError),
49    NoReadableContent { name: String },
50}
51
52/// Native-manual resolution or lowering failed after candidate selection.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum ManualLoadError {
55    NotFound {
56        name: String,
57        detail: String,
58    },
59    Parse {
60        name: String,
61        detail: String,
62    },
63    Empty {
64        name: String,
65        path: PathBuf,
66        diagnostics: Vec<String>,
67    },
68}
69
70/// Materialized result of the view carried by a [`QueryRequest`].
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum QueryViewResult {
73    Full(QueryBundle),
74    Outline(QueryOutline),
75    Excerpt(QueryExcerpt),
76    Search(QuerySearch),
77}
78
79/// A valid request could not be loaded or projected.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum QueryExecutionError {
82    Query(QueryError),
83    Projection(ProjectionError),
84    Search(SearchError),
85}
86
87/// Input-resolution policy kept outside the serialized request contract.
88#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
89pub struct QueryPolicy {
90    /// Bypass registered Markdown and require a readable native manual.
91    pub manual_only: bool,
92}
93
94impl fmt::Display for QueryError {
95    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96        match self {
97            Self::EmptyName => formatter.write_str("name must not be empty"),
98            Self::InvalidSection => formatter.write_str("manual section must not be empty"),
99            Self::InvalidSource => formatter.write_str("document source must not be empty"),
100            Self::ConflictingSourceSelectors => formatter.write_str(
101                "document source cannot be combined with a manual section or manual-only policy",
102            ),
103            Self::EmptyMarkdownPath => formatter.write_str("Markdown path must not be empty"),
104            Self::EmptySelection => formatter.write_str("at least one outline node is required"),
105            Self::EmptySelector => formatter.write_str("outline node must not be empty"),
106            Self::EmptyEntry => formatter.write_str("semantic entry must not be empty"),
107            Self::InvalidSearch(error) => error.fmt(formatter),
108            Self::Markdown { path, detail } => {
109                write!(
110                    formatter,
111                    "could not load Markdown document '{path}': {detail}"
112                )
113            }
114            Self::EmptyMarkdown { label } => {
115                write!(
116                    formatter,
117                    "Markdown document '{label}' has no readable content"
118                )
119            }
120            Self::Registry { detail } => formatter.write_str(detail),
121            Self::Manual(error) => error.fmt(formatter),
122            Self::NoReadableContent { name } => {
123                write!(
124                    formatter,
125                    "no readable document content was found for '{name}'"
126                )
127            }
128        }
129    }
130}
131
132impl Error for QueryError {
133    fn source(&self) -> Option<&(dyn Error + 'static)> {
134        match self {
135            Self::InvalidSearch(error) => Some(error),
136            Self::Manual(error) => Some(error),
137            Self::EmptyName
138            | Self::InvalidSection
139            | Self::InvalidSource
140            | Self::ConflictingSourceSelectors
141            | Self::EmptyMarkdownPath
142            | Self::EmptySelection
143            | Self::EmptySelector
144            | Self::EmptyEntry
145            | Self::Markdown { .. }
146            | Self::EmptyMarkdown { .. }
147            | Self::Registry { .. }
148            | Self::NoReadableContent { .. } => None,
149        }
150    }
151}
152
153impl fmt::Display for ManualLoadError {
154    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
155        match self {
156            Self::NotFound { name, detail } | Self::Parse { name, detail } => {
157                write!(
158                    formatter,
159                    "could not load manual '{name}': manual source: {detail}"
160                )
161            }
162            Self::Empty {
163                name,
164                path,
165                diagnostics,
166            } => {
167                write!(
168                    formatter,
169                    "could not load manual '{name}': libmandoc parsed {} but produced no readable sections",
170                    path.display()
171                )?;
172                if !diagnostics.is_empty() {
173                    write!(formatter, "; diagnostics: {}", diagnostics.join("; "))?;
174                }
175                Ok(())
176            }
177        }
178    }
179}
180
181impl Error for ManualLoadError {}
182
183impl fmt::Display for QueryExecutionError {
184    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185        match self {
186            Self::Query(error) => error.fmt(formatter),
187            Self::Projection(error) => error.fmt(formatter),
188            Self::Search(error) => error.fmt(formatter),
189        }
190    }
191}
192
193impl Error for QueryExecutionError {
194    fn source(&self) -> Option<&(dyn Error + 'static)> {
195        match self {
196            Self::Query(error) => Some(error),
197            Self::Projection(error) => Some(error),
198            Self::Search(error) => Some(error),
199        }
200    }
201}
202
203/// Query the local man database and optional offline tldr caches.
204///
205/// # Errors
206///
207/// Returns [`QueryError`] for invalid input or when neither source can produce
208/// readable content.
209pub fn resolve_query(request: &QueryRequest) -> Result<QueryBundle, QueryError> {
210    resolve_query_with_policy(request, QueryPolicy::default())
211}
212
213/// Query with an explicit input-resolution policy.
214///
215/// # Errors
216///
217/// Returns [`QueryError`] under the same conditions as [`resolve_query`].
218pub fn resolve_query_with_policy(
219    request: &QueryRequest,
220    policy: QueryPolicy,
221) -> Result<QueryBundle, QueryError> {
222    let resolver = DocumentResolver::from_system();
223    resolver.resolve(request, policy)
224}
225
226/// Load and materialize the view encoded in one native request.
227///
228/// # Errors
229///
230/// Returns a typed loading, projection, or search failure.
231pub fn execute_query(
232    request: &QueryRequest,
233    policy: QueryPolicy,
234) -> Result<QueryViewResult, QueryExecutionError> {
235    let resolver = DocumentResolver::from_system();
236    resolver.execute(request, policy)
237}
238
239/// Materialize one view from an already loaded query.
240///
241/// # Errors
242///
243/// Returns a typed projection or search failure.
244pub fn project_query_view(
245    query: QueryBundle,
246    view: &QueryView,
247) -> Result<QueryViewResult, QueryExecutionError> {
248    match view {
249        QueryView::Full {} => Ok(QueryViewResult::Full(query)),
250        QueryView::Outline { detail } => build_outline_with_detail(&query, *detail)
251            .map(QueryViewResult::Outline)
252            .map_err(QueryExecutionError::Projection),
253        QueryView::Excerpt { nodes } => select_excerpt(&query, nodes)
254            .map(QueryViewResult::Excerpt)
255            .map_err(QueryExecutionError::Projection),
256        QueryView::Explain { entry } => select_explanation(&query, entry)
257            .map(QueryViewResult::Excerpt)
258            .map_err(QueryExecutionError::Projection),
259        QueryView::Search {
260            pattern,
261            syntax,
262            case,
263            scope,
264            word,
265            context_lines,
266            limit,
267            offset,
268        } => search_query(
269            &query,
270            &SearchQuery {
271                pattern: pattern.clone(),
272                syntax: *syntax,
273                case: *case,
274                scope: *scope,
275                word: *word,
276                context_lines: *context_lines,
277                limit: *limit,
278                offset: *offset,
279            },
280        )
281        .map(QueryViewResult::Search)
282        .map_err(QueryExecutionError::Search),
283    }
284}
285
286/// Validate all request and policy invariants before local I/O.
287///
288/// # Errors
289///
290/// Returns the exact invalid input constraint.
291pub fn validate_query_request(
292    request: &QueryRequest,
293    policy: QueryPolicy,
294) -> Result<(), QueryError> {
295    match &request.input {
296        QueryInput::Document {
297            name,
298            source,
299            section,
300        } => {
301            if name.trim().is_empty() {
302                return Err(QueryError::EmptyName);
303            }
304            if source
305                .as_deref()
306                .is_some_and(|value| value.trim().is_empty())
307            {
308                return Err(QueryError::InvalidSource);
309            }
310            if section
311                .as_deref()
312                .is_some_and(|value| value.trim().is_empty())
313            {
314                return Err(QueryError::InvalidSection);
315            }
316            if source.is_some() && (section.is_some() || policy.manual_only) {
317                return Err(QueryError::ConflictingSourceSelectors);
318            }
319        }
320        QueryInput::MarkdownFile { path } => {
321            if path.trim().is_empty() {
322                return Err(QueryError::EmptyMarkdownPath);
323            }
324            if policy.manual_only {
325                return Err(QueryError::Markdown {
326                    path: path.trim().to_owned(),
327                    detail: "the manual-only policy does not apply to Markdown input".to_owned(),
328                });
329            }
330        }
331    }
332    match &request.view {
333        QueryView::Excerpt { nodes } => {
334            if nodes.is_empty() {
335                return Err(QueryError::EmptySelection);
336            }
337            if nodes.iter().any(|node| node.trim().is_empty()) {
338                return Err(QueryError::EmptySelector);
339            }
340        }
341        QueryView::Explain { entry } if entry.trim().is_empty() => {
342            return Err(QueryError::EmptyEntry);
343        }
344        QueryView::Search {
345            pattern,
346            syntax,
347            case,
348            scope,
349            word,
350            context_lines,
351            limit,
352            offset,
353        } => validate_search_query(&SearchQuery {
354            pattern: pattern.clone(),
355            syntax: *syntax,
356            case: *case,
357            scope: *scope,
358            word: *word,
359            context_lines: *context_lines,
360            limit: *limit,
361            offset: *offset,
362        })
363        .map_err(QueryError::InvalidSearch)?,
364        QueryView::Full {} | QueryView::Outline { .. } | QueryView::Explain { .. } => {}
365    }
366    Ok(())
367}
368
369trait QueryHost {
370    fn name_candidates(&self, name: &str) -> Vec<String>;
371    fn locate_registered_document(
372        &self,
373        candidates: &[String],
374        source: Option<&str>,
375    ) -> Result<Option<PathBuf>, String>;
376    fn locate_manual(&self, request: &ManualRequest) -> Result<ManualPage, String>;
377    fn parse_manual(&self, page: &ManualPage) -> Result<MantDocument, String>;
378    fn read_tldr(&self, name: &str) -> Result<Option<TldrDocument>, String>;
379    fn read_markdown(&self, path: &Path) -> Result<String, String>;
380}
381
382/// One explicit local document-environment snapshot.
383pub struct DocumentResolver {
384    registered: OnceLock<Result<RegisteredDocumentIndex, SourceConfigError>>,
385    manual_roots: Vec<PathBuf>,
386    manuals: OnceLock<ManualIndex>,
387}
388
389impl DocumentResolver {
390    /// Capture the native manual index and lazily snapshot Markdown registration.
391    #[must_use]
392    pub fn from_system() -> Self {
393        Self {
394            registered: OnceLock::new(),
395            manual_roots: discover_manual_roots(),
396            manuals: OnceLock::new(),
397        }
398    }
399
400    /// Validate and resolve one request against this environment snapshot.
401    ///
402    /// Reusing a resolver keeps manual and registered-document precedence
403    /// stable across related operations. Construct a new resolver to refresh
404    /// filesystem discovery.
405    ///
406    /// # Errors
407    ///
408    /// Returns [`QueryError`] for invalid input or unreadable local content.
409    pub fn resolve(
410        &self,
411        request: &QueryRequest,
412        policy: QueryPolicy,
413    ) -> Result<QueryBundle, QueryError> {
414        validate_query_request(request, policy)?;
415        query_with(request, policy, self)
416    }
417
418    /// Resolve and materialize the request's encoded view.
419    ///
420    /// # Errors
421    ///
422    /// Returns a typed loading, projection, or search failure.
423    pub fn execute(
424        &self,
425        request: &QueryRequest,
426        policy: QueryPolicy,
427    ) -> Result<QueryViewResult, QueryExecutionError> {
428        let query = self
429            .resolve(request, policy)
430            .map_err(QueryExecutionError::Query)?;
431        project_query_view(query, &request.view)
432    }
433}
434
435impl QueryHost for DocumentResolver {
436    fn name_candidates(&self, name: &str) -> Vec<String> {
437        query_name_candidates(name)
438    }
439
440    fn locate_registered_document(
441        &self,
442        candidates: &[String],
443        source: Option<&str>,
444    ) -> Result<Option<PathBuf>, String> {
445        let index = self
446            .registered
447            .get_or_init(RegisteredDocumentIndex::load)
448            .as_ref()
449            .map_err(ToString::to_string)?;
450        index
451            .find(candidates, source)
452            .map(|registered| registered.map(|registered| registered.path.clone()))
453            .map_err(|error| error.to_string())
454    }
455
456    fn locate_manual(&self, request: &ManualRequest) -> Result<ManualPage, String> {
457        let manuals = self
458            .manuals
459            .get_or_init(|| ManualIndex::from_roots(self.manual_roots.clone()));
460        locate_manual_source_in(request, manuals).map_err(|error| error.to_string())
461    }
462
463    fn parse_manual(&self, page: &ManualPage) -> Result<MantDocument, String> {
464        parse_manual_page(page).map_err(|error| error.to_string())
465    }
466
467    fn read_tldr(&self, name: &str) -> Result<Option<TldrDocument>, String> {
468        read_cached_tldr_page(name).map_err(|error| error.to_string())
469    }
470
471    fn read_markdown(&self, path: &Path) -> Result<String, String> {
472        let file = fs::File::open(path).map_err(|error| error.to_string())?;
473        read_capped_utf8(file, MAX_MARKDOWN_BYTES)
474    }
475}
476
477/// Read at most `limit` bytes of UTF-8, rejecting anything larger.
478///
479/// The reader is bounded directly instead of trusting a reported length: a pipe
480/// or character device such as `/dev/zero` reports no size yet streams without
481/// end, so only capping the byte count keeps the read finite.
482fn read_capped_utf8(reader: impl Read, limit: u64) -> Result<String, String> {
483    read_capped_utf8_io(reader, limit).map_err(|error| error.to_string())
484}
485
486/// Read bounded UTF-8 while preserving failures from the underlying reader.
487pub(crate) fn read_capped_utf8_io(reader: impl Read, limit: u64) -> io::Result<String> {
488    crate::bounded::read_utf8(reader, limit, "Markdown document")
489}
490
491fn query_with(
492    request: &QueryRequest,
493    policy: QueryPolicy,
494    host: &dyn QueryHost,
495) -> Result<QueryBundle, QueryError> {
496    match &request.input {
497        QueryInput::Document {
498            name,
499            source,
500            section,
501        } => query_named_document(name, source.as_deref(), section.as_deref(), policy, host),
502        QueryInput::MarkdownFile { path } => query_markdown_file(path, policy, host),
503    }
504}
505
506fn query_markdown_file(
507    requested_path: &str,
508    policy: QueryPolicy,
509    host: &dyn QueryHost,
510) -> Result<QueryBundle, QueryError> {
511    let path = requested_path.trim();
512    if path.is_empty() {
513        return Err(QueryError::EmptyMarkdownPath);
514    }
515    if policy.manual_only {
516        return Err(QueryError::Markdown {
517            path: path.to_owned(),
518            detail: "the manual-only policy does not apply to Markdown input".to_owned(),
519        });
520    }
521    let source = host
522        .read_markdown(Path::new(path))
523        .map_err(|detail| QueryError::Markdown {
524            path: path.to_owned(),
525            detail,
526        })?;
527    query_markdown_text(&source, Some(path.to_owned()))
528}
529
530/// Parse in-memory Markdown for the direct `mant -` command.
531///
532/// This helper intentionally sits outside [`QueryRequest`]: public protocol
533/// requests reference local files and never embed arbitrary document content.
534///
535/// # Errors
536///
537/// Returns [`QueryError::EmptyMarkdown`] when parsing yields no visible blocks
538/// or sections.
539pub fn query_markdown_text(
540    source: &str,
541    source_path: Option<String>,
542) -> Result<QueryBundle, QueryError> {
543    let label = source_path.as_deref().map_or_else(
544        || "stdin".to_owned(),
545        |path| {
546            Path::new(path)
547                .file_name()
548                .and_then(OsStr::to_str)
549                .unwrap_or(path)
550                .to_owned()
551        },
552    );
553    let error_path = source_path.clone().unwrap_or_else(|| "stdin".to_owned());
554    let parsed = parse_markdown(source, source_path).map_err(|error| QueryError::Markdown {
555        path: error_path,
556        detail: error.to_string(),
557    })?;
558    let document_is_empty =
559        parsed.document.blocks.is_empty() && parsed.document.sections.is_empty();
560    if document_is_empty && parsed.tldr.is_none() {
561        return Err(QueryError::EmptyMarkdown {
562            label: label.clone(),
563        });
564    }
565    Ok(QueryBundle {
566        schema: QuerySchema::V6,
567        label,
568        document: (!document_is_empty).then_some(parsed.document),
569        tldr: parsed.tldr,
570    })
571}
572
573fn query_named_document(
574    name: &str,
575    requested_source: Option<&str>,
576    requested_section: Option<&str>,
577    policy: QueryPolicy,
578    host: &dyn QueryHost,
579) -> Result<QueryBundle, QueryError> {
580    let name = name.trim();
581    if name.is_empty() {
582        return Err(QueryError::EmptyName);
583    }
584    let section = requested_section.map(str::trim);
585    if section.is_some_and(str::is_empty) {
586        return Err(QueryError::InvalidSection);
587    }
588    let section = section.map(ToOwned::to_owned);
589    let source = requested_source.map(str::trim);
590    if source.is_some_and(str::is_empty) {
591        return Err(QueryError::InvalidSource);
592    }
593    if source.is_some() && (section.is_some() || policy.manual_only) {
594        return Err(QueryError::ConflictingSourceSelectors);
595    }
596    let require_manual = policy.manual_only || section.is_some();
597    let candidates = host.name_candidates(name);
598
599    // An unqualified name first consults one snapshot of the platform-native
600    // registration namespace. Section selectors and the explicit manual-only
601    // policy bypass Markdown name discovery.
602    if section.is_none() && !policy.manual_only {
603        let registered = host
604            .locate_registered_document(&candidates, source)
605            .map_err(|detail| QueryError::Registry { detail })?;
606        if let Some(path) = registered {
607            return query_registered_document(name, &path, host);
608        }
609        if source.is_some() {
610            return Err(QueryError::NoReadableContent {
611                name: name.to_owned(),
612            });
613        }
614    }
615
616    // A malformed or unreadable community cache must never hide a valid man
617    // page. It is an optional augmentation and is never updated during query.
618    let tldr = host.read_tldr(name).ok().flatten();
619    let mut manual = load_manual(name, &candidates, section.as_deref(), host);
620
621    // A malformed page may omit its own section metadata. Preserve the
622    // requested section so labels stay `name(N)`.
623    if let (Ok(document), Some(section)) = (&mut manual, section.as_deref())
624        && document.meta.section.is_none()
625    {
626        document.meta.section = Some(section.to_owned());
627    }
628
629    // An explicit manual request may include tldr beside a successful manual,
630    // but must not degrade into an apparently successful tldr-only response.
631    if require_manual {
632        return match manual {
633            Ok(manual) => Ok(QueryBundle {
634                schema: QuerySchema::V6,
635                label: name.to_owned(),
636                document: Some(manual),
637                tldr,
638            }),
639            Err(error) => Err(QueryError::Manual(error)),
640        };
641    }
642
643    match manual {
644        Ok(manual) => Ok(QueryBundle {
645            schema: QuerySchema::V6,
646            label: name.to_owned(),
647            document: Some(manual),
648            tldr,
649        }),
650        Err(_) if tldr.is_some() => Ok(QueryBundle {
651            schema: QuerySchema::V6,
652            label: name.to_owned(),
653            document: None,
654            tldr,
655        }),
656        Err(error) => Err(QueryError::Manual(error)),
657    }
658}
659
660fn query_registered_document(
661    name: &str,
662    path: &Path,
663    host: &dyn QueryHost,
664) -> Result<QueryBundle, QueryError> {
665    let source_path = path.to_string_lossy().into_owned();
666    let source = host
667        .read_markdown(path)
668        .map_err(|detail| QueryError::Markdown {
669            path: source_path.clone(),
670            detail,
671        })?;
672    let mut query = query_markdown_text(&source, Some(source_path))?;
673    name.clone_into(&mut query.label);
674    Ok(query)
675}
676
677fn load_manual(
678    requested_name: &str,
679    candidates: &[String],
680    section: Option<&str>,
681    host: &dyn QueryHost,
682) -> Result<MantDocument, ManualLoadError> {
683    let mut first_locate_error = None;
684    let mut located = None;
685    for candidate in candidates {
686        let request = ManualRequest::new(candidate, section.map(ToOwned::to_owned));
687        match host.locate_manual(&request) {
688            Ok(page) => {
689                located = Some(page);
690                break;
691            }
692            Err(error) => {
693                first_locate_error.get_or_insert(error);
694            }
695        }
696    }
697    let Some(page) = located else {
698        let error =
699            first_locate_error.unwrap_or_else(|| "no name candidates were available".to_owned());
700        return Err(ManualLoadError::NotFound {
701            name: requested_name.to_owned(),
702            detail: error,
703        });
704    };
705
706    let source_path = page.path.clone();
707    let document = host
708        .parse_manual(&page)
709        .map_err(|detail| ManualLoadError::Parse {
710            name: requested_name.to_owned(),
711            detail,
712        })?;
713    if document.sections.is_empty() {
714        let diagnostics = document
715            .diagnostics
716            .iter()
717            .map(|diagnostic| {
718                let location = diagnostic.source.map_or_else(String::new, |source| {
719                    format!(" at {}:{}", source.line, source.column)
720                });
721                format!("{:?}{location}: {}", diagnostic.level, diagnostic.message)
722            })
723            .collect::<Vec<_>>();
724        return Err(ManualLoadError::Empty {
725            name: requested_name.to_owned(),
726            path: source_path,
727            diagnostics,
728        });
729    }
730    Ok(document)
731}
732
733#[cfg(test)]
734mod tests {
735    use std::{
736        io,
737        path::{Path, PathBuf},
738        sync::Mutex,
739    };
740
741    use mant_ast::{
742        Diagnostic, DiagnosticLevel, DocumentMeta, DocumentSchema, DocumentSource, MantDocument,
743        Producer, QueryInput, QueryRequest, QueryView, RequestSchema, Section, SourceFormat,
744        TldrDocument, TldrOrigin,
745    };
746
747    use crate::{ManualPage, ManualRequest};
748
749    use super::{
750        MAX_MARKDOWN_BYTES, QueryError, QueryHost, QueryPolicy, query_markdown_text, query_with,
751        read_capped_utf8, read_capped_utf8_io,
752    };
753
754    #[derive(Clone)]
755    struct StubHost {
756        name_candidates: Option<Vec<String>>,
757        registered_document: Option<PathBuf>,
758        registered_name: Option<String>,
759        locate: Result<ManualPage, String>,
760        manual_name: Option<String>,
761        direct: Result<MantDocument, String>,
762        tldr: Result<Option<TldrDocument>, String>,
763        markdown: Result<String, String>,
764        calls: std::sync::Arc<Mutex<Vec<&'static str>>>,
765    }
766
767    impl QueryHost for StubHost {
768        fn name_candidates(&self, name: &str) -> Vec<String> {
769            self.name_candidates
770                .clone()
771                .unwrap_or_else(|| vec![name.to_owned()])
772        }
773
774        fn locate_registered_document(
775            &self,
776            candidates: &[String],
777            source: Option<&str>,
778        ) -> Result<Option<PathBuf>, String> {
779            self.calls
780                .lock()
781                .expect("calls lock")
782                .push(if source.is_some() { "source" } else { "name" });
783            if self
784                .registered_name
785                .as_deref()
786                .is_some_and(|registered_name| {
787                    !candidates
788                        .iter()
789                        .any(|candidate| candidate == registered_name)
790                })
791            {
792                return Ok(None);
793            }
794            Ok(self.registered_document.clone())
795        }
796
797        fn locate_manual(&self, request: &ManualRequest) -> Result<ManualPage, String> {
798            self.calls.lock().expect("calls lock").push("locate");
799            if self
800                .manual_name
801                .as_deref()
802                .is_some_and(|manual_name| manual_name != request.name)
803            {
804                return Err("source not found".to_owned());
805            }
806            self.locate.clone()
807        }
808
809        fn parse_manual(&self, _page: &ManualPage) -> Result<MantDocument, String> {
810            self.calls.lock().expect("calls lock").push("parse");
811            self.direct.clone()
812        }
813
814        fn read_tldr(&self, _name: &str) -> Result<Option<TldrDocument>, String> {
815            self.calls.lock().expect("calls lock").push("tldr");
816            self.tldr.clone()
817        }
818
819        fn read_markdown(&self, _path: &Path) -> Result<String, String> {
820            self.calls.lock().expect("calls lock").push("markdown");
821            self.markdown.clone()
822        }
823    }
824
825    fn document(format: SourceFormat, unsupported: bool, readable: bool) -> MantDocument {
826        MantDocument {
827            schema: DocumentSchema::V6,
828            producer: Producer {
829                name: "test".to_owned(),
830                version: "1".to_owned(),
831                engine: None,
832            },
833            source: DocumentSource { format, path: None },
834            meta: DocumentMeta::default(),
835            diagnostics: unsupported
836                .then_some(Diagnostic {
837                    level: DiagnosticLevel::Unsupported,
838                    code: None,
839                    message: "unsupported request".to_owned(),
840                    source: None,
841                })
842                .into_iter()
843                .collect(),
844            blocks: Vec::new(),
845            sections: readable
846                .then_some(Section {
847                    id: "name-1".to_owned(),
848                    title: "NAME".to_owned(),
849                    spacing_before_lines: 0,
850                    blocks: Vec::new(),
851                    children: Vec::new(),
852                    source: None,
853                })
854                .into_iter()
855                .collect(),
856        }
857    }
858
859    fn tldr() -> TldrDocument {
860        TldrDocument {
861            title: "tool".to_owned(),
862            description: vec!["quick reference".to_owned()],
863            more_information: None,
864            examples: Vec::new(),
865            platform: "common".to_owned(),
866            language: "en".to_owned(),
867            source_path: "/cache/pages/common/tool.md".to_owned(),
868            origin: TldrOrigin::TldrPages,
869        }
870    }
871
872    fn host(direct: Result<MantDocument, String>) -> StubHost {
873        StubHost {
874            name_candidates: None,
875            registered_document: None,
876            registered_name: None,
877            locate: Ok(ManualPage {
878                name: "tool".to_owned(),
879                section: "1".to_owned(),
880                path: PathBuf::from("/man/tool.1"),
881                manual_root: PathBuf::from("/man"),
882            }),
883            manual_name: None,
884            direct,
885            tldr: Ok(None),
886            markdown: Err("Markdown unavailable".to_owned()),
887            calls: std::sync::Arc::default(),
888        }
889    }
890
891    fn request() -> QueryRequest {
892        QueryRequest {
893            schema: RequestSchema::V6,
894            input: QueryInput::Document {
895                name: " tool ".to_owned(),
896                source: None,
897                section: None,
898            },
899            view: QueryView::Full {},
900        }
901    }
902
903    #[test]
904    fn ordinary_manual_uses_the_native_parser() {
905        let host = host(Ok(document(SourceFormat::Man, false, true)));
906        let result = query_with(&request(), QueryPolicy::default(), &host).expect("query");
907
908        assert_eq!(result.label, "tool");
909        assert_eq!(
910            result.document.expect("manual").source.format,
911            SourceFormat::Man
912        );
913        assert_eq!(
914            *host.calls.lock().expect("calls lock"),
915            ["name", "tldr", "locate", "parse"]
916        );
917    }
918
919    #[test]
920    fn requested_section_backfills_metadata_the_parser_left_empty() {
921        let host = host(Ok(document(SourceFormat::Man, false, true)));
922        let request = QueryRequest {
923            schema: RequestSchema::V6,
924            input: QueryInput::Document {
925                name: "tool".to_owned(),
926                source: None,
927                section: Some("3".to_owned()),
928            },
929            view: QueryView::Full {},
930        };
931
932        let result = query_with(&request, QueryPolicy::default(), &host).expect("query");
933        assert_eq!(
934            result.document.expect("manual").meta.section.as_deref(),
935            Some("3"),
936            "requested section must label output when the parser omits it"
937        );
938        assert_eq!(
939            *host.calls.lock().expect("calls lock"),
940            ["tldr", "locate", "parse"],
941            "an explicit manual section bypasses registered Markdown"
942        );
943    }
944
945    #[test]
946    fn explicit_source_reads_only_registered_markdown() {
947        let mut host = host(Err("manual must not be read".to_owned()));
948        host.registered_document = Some(PathBuf::from("/documents/tool.md"));
949        host.markdown = Ok("# Tool\n\nSource body.\n".to_owned());
950        let request = QueryRequest {
951            schema: RequestSchema::V6,
952            input: QueryInput::Document {
953                name: "tool".to_owned(),
954                source: Some("team".to_owned()),
955                section: None,
956            },
957            view: QueryView::Full {},
958        };
959        let result = query_with(&request, QueryPolicy::default(), &host).expect("source query");
960        assert_eq!(
961            result.document.expect("Markdown").meta.title.as_deref(),
962            Some("Tool")
963        );
964        assert_eq!(
965            *host.calls.lock().expect("calls lock"),
966            ["source", "markdown"]
967        );
968    }
969
970    #[test]
971    fn complete_direct_document_survives_an_unsupported_finding() {
972        let host = host(Ok(document(SourceFormat::Man, true, true)));
973        let result = query_with(&request(), QueryPolicy::default(), &host).expect("query");
974
975        assert_eq!(
976            result.document.expect("manual").source.format,
977            SourceFormat::Man
978        );
979        assert_eq!(
980            *host.calls.lock().expect("calls lock"),
981            ["name", "tldr", "locate", "parse"]
982        );
983    }
984
985    #[test]
986    fn manual_only_bypasses_registered_markdown() {
987        let mut host = host(Ok(document(SourceFormat::Man, true, true)));
988        host.registered_document = Some(PathBuf::from("/data/mant/tool.md"));
989        host.markdown = Ok("# Registered".to_owned());
990        let result = query_with(&request(), QueryPolicy { manual_only: true }, &host)
991            .expect("manual-only query");
992
993        assert_eq!(
994            result.document.expect("manual").source.format,
995            SourceFormat::Man
996        );
997        assert_eq!(
998            *host.calls.lock().expect("calls lock"),
999            ["tldr", "locate", "parse"],
1000            "manual-only lookup must not inspect the registered-document namespace"
1001        );
1002    }
1003
1004    #[test]
1005    fn manual_only_failure_is_not_hidden_by_tldr() {
1006        let mut host = host(Ok(document(SourceFormat::Man, true, false)));
1007        host.tldr = Ok(Some(tldr()));
1008
1009        let error = query_with(&request(), QueryPolicy { manual_only: true }, &host)
1010            .expect_err("an optional tldr page must not hide native parser failure");
1011
1012        let QueryError::Manual(detail) = error else {
1013            panic!("expected the native parser diagnostic");
1014        };
1015        assert!(detail.to_string().contains("/man/tool.1"));
1016        assert!(
1017            detail
1018                .to_string()
1019                .contains("Unsupported: unsupported request")
1020        );
1021        assert_eq!(
1022            *host.calls.lock().expect("calls lock"),
1023            ["tldr", "locate", "parse"]
1024        );
1025    }
1026
1027    #[test]
1028    fn requested_section_failure_is_not_hidden_by_tldr() {
1029        let mut host = host(Err("libmandoc failed".to_owned()));
1030        host.locate = Err("section not found".to_owned());
1031        host.tldr = Ok(Some(tldr()));
1032        let request = QueryRequest {
1033            schema: RequestSchema::V6,
1034            input: QueryInput::Document {
1035                name: "tool".to_owned(),
1036                source: None,
1037                section: Some("7".to_owned()),
1038            },
1039            view: QueryView::Full {},
1040        };
1041
1042        let error = query_with(&request, QueryPolicy::default(), &host)
1043            .expect_err("an explicit section must require a native manual");
1044
1045        assert!(matches!(&error, QueryError::Manual(_)));
1046        assert!(error.to_string().contains("section not found"));
1047        assert_eq!(*host.calls.lock().expect("calls lock"), ["tldr", "locate"]);
1048    }
1049
1050    #[test]
1051    fn truncated_unsupported_document_is_an_error_by_default() {
1052        let host = host(Ok(document(SourceFormat::Man, true, false)));
1053
1054        let QueryError::Manual(detail) = query_with(&request(), QueryPolicy::default(), &host)
1055            .expect_err("empty-section document must error by default")
1056        else {
1057            panic!("expected Manual error");
1058        };
1059        assert!(detail.to_string().contains("produced no readable sections"));
1060    }
1061
1062    #[test]
1063    fn readable_best_effort_document_survives_parser_findings() {
1064        let host = host(Ok(document(SourceFormat::Mdoc, true, true)));
1065        let result = query_with(&request(), QueryPolicy::default(), &host).expect("query");
1066        assert_eq!(
1067            result.document.expect("manual").source.format,
1068            SourceFormat::Mdoc
1069        );
1070    }
1071
1072    #[test]
1073    fn cached_tldr_survives_total_manual_failure() {
1074        let mut host = host(Err("libmandoc failed".to_owned()));
1075        host.locate = Err("source not found".to_owned());
1076        host.tldr = Ok(Some(tldr()));
1077        let result =
1078            query_with(&request(), QueryPolicy::default(), &host).expect("tldr-only query");
1079
1080        assert!(result.document.is_none());
1081        assert_eq!(result.tldr.expect("tldr").title, "tool");
1082    }
1083
1084    #[test]
1085    fn reports_both_manual_paths_when_no_content_exists() {
1086        let mut host = host(Err("libmandoc failed".to_owned()));
1087        host.locate = Err("source not found".to_owned());
1088        let error = query_with(&request(), QueryPolicy::default(), &host)
1089            .expect_err("empty query must fail");
1090        assert_eq!(
1091            error.to_string(),
1092            "could not load manual 'tool': manual source: source not found"
1093        );
1094    }
1095
1096    #[test]
1097    fn validates_before_touching_host_state() {
1098        let host = host(Ok(document(SourceFormat::Man, false, true)));
1099        assert_eq!(
1100            query_with(
1101                &QueryRequest {
1102                    schema: RequestSchema::V6,
1103                    input: QueryInput::Document {
1104                        name: " ".to_owned(),
1105                        source: None,
1106                        section: None,
1107                    },
1108                    view: QueryView::Full {},
1109                },
1110                QueryPolicy::default(),
1111                &host
1112            ),
1113            Err(QueryError::EmptyName)
1114        );
1115        assert!(host.calls.lock().expect("calls lock").is_empty());
1116    }
1117
1118    #[test]
1119    fn registered_markdown_shadows_an_unqualified_manual_name() {
1120        let mut host = host(Err("manual parser must not run".to_owned()));
1121        host.registered_document = Some(PathBuf::from("/data/mant/tool.md"));
1122        host.markdown = Ok("# Tool\n\n## Options\n\n- `--help`: Show help.\n".to_owned());
1123
1124        let result = query_with(&request(), QueryPolicy::default(), &host)
1125            .expect("registered Markdown name");
1126
1127        assert_eq!(result.label, "tool");
1128        assert!(result.tldr.is_none());
1129        let document = result.document.expect("registered document");
1130        assert_eq!(document.source.format, SourceFormat::Markdown);
1131        assert_eq!(document.source.path.as_deref(), Some("/data/mant/tool.md"));
1132        assert_eq!(
1133            *host.calls.lock().expect("calls lock"),
1134            ["name", "markdown"],
1135            "a registered name must not consult man or external tldr caches"
1136        );
1137    }
1138
1139    #[test]
1140    fn windows_suffix_fallback_can_resolve_registered_markdown() {
1141        let mut host = host(Err("manual parser must not run".to_owned()));
1142        host.name_candidates = Some(vec!["tool".to_owned(), "tool.EXE".to_owned()]);
1143        host.registered_name = Some("tool.EXE".to_owned());
1144        host.registered_document = Some(PathBuf::from("/data/mant/tool.exe.md"));
1145        host.markdown = Ok("# Tool executable\n\nWindows command documentation.\n".to_owned());
1146
1147        let result = query_with(&request(), QueryPolicy::default(), &host)
1148            .expect("registered executable document");
1149
1150        assert_eq!(result.label, "tool");
1151        assert_eq!(
1152            result.document.expect("document").source.path.as_deref(),
1153            Some("/data/mant/tool.exe.md")
1154        );
1155        assert_eq!(
1156            *host.calls.lock().expect("calls lock"),
1157            ["name", "markdown"]
1158        );
1159    }
1160
1161    #[test]
1162    fn windows_suffix_fallback_can_resolve_a_native_manual() {
1163        let mut host = host(Ok(document(SourceFormat::Man, false, true)));
1164        host.name_candidates = Some(vec!["tool".to_owned(), "tool.EXE".to_owned()]);
1165        host.manual_name = Some("tool.EXE".to_owned());
1166        host.locate = Ok(ManualPage {
1167            name: "tool.exe".to_owned(),
1168            section: "1".to_owned(),
1169            path: PathBuf::from("/man/tool.exe.1"),
1170            manual_root: PathBuf::from("/man"),
1171        });
1172
1173        let result = query_with(&request(), QueryPolicy::default(), &host)
1174            .expect("native executable manual");
1175
1176        assert_eq!(result.label, "tool");
1177        assert_eq!(
1178            result.document.expect("manual").source.format,
1179            SourceFormat::Man
1180        );
1181        assert_eq!(
1182            *host.calls.lock().expect("calls lock"),
1183            ["name", "tldr", "locate", "locate", "parse"]
1184        );
1185    }
1186
1187    #[test]
1188    fn exact_names_win_before_windows_suffix_fallback() {
1189        let mut host = host(Err("manual parser must not run".to_owned()));
1190        host.name_candidates = Some(vec!["tool".to_owned(), "tool.EXE".to_owned()]);
1191        host.registered_document = Some(PathBuf::from("/data/mant/tool.md"));
1192        host.markdown = Ok("# Exact tool\n\nExact-name documentation.\n".to_owned());
1193
1194        let result = query_with(&request(), QueryPolicy::default(), &host)
1195            .expect("exact registered document");
1196
1197        assert_eq!(
1198            result.document.expect("document").source.path.as_deref(),
1199            Some("/data/mant/tool.md")
1200        );
1201        assert_eq!(
1202            *host.calls.lock().expect("calls lock"),
1203            ["name", "markdown"]
1204        );
1205    }
1206
1207    #[test]
1208    fn markdown_files_bypass_manual_and_tldr_sources() {
1209        let mut host = host(Err("manual parser must not run".to_owned()));
1210        host.markdown = Ok("# Tool\n\n## Options\n\n- `--help`: Show help.\n".to_owned());
1211        let result = query_with(
1212            &QueryRequest {
1213                schema: RequestSchema::V6,
1214                input: QueryInput::MarkdownFile {
1215                    path: "docs/tool.md".to_owned(),
1216                },
1217                view: QueryView::Full {},
1218            },
1219            QueryPolicy::default(),
1220            &host,
1221        )
1222        .expect("Markdown query");
1223
1224        assert_eq!(result.label, "tool.md");
1225        assert!(result.tldr.is_none());
1226        let document = result.document.expect("document");
1227        assert_eq!(document.source.format, SourceFormat::Markdown);
1228        assert_eq!(document.source.path.as_deref(), Some("docs/tool.md"));
1229        assert_eq!(
1230            *host.calls.lock().expect("calls lock"),
1231            ["markdown"],
1232            "Markdown must not consult man or tldr"
1233        );
1234    }
1235
1236    #[test]
1237    fn in_memory_markdown_is_available_without_a_protocol_content_field() {
1238        let result = query_markdown_text("# Piped\n\nBody.\n", None).expect("stdin Markdown query");
1239
1240        assert_eq!(result.label, "stdin");
1241        assert!(result.tldr.is_none());
1242        let document = result.document.expect("document");
1243        assert_eq!(document.meta.title.as_deref(), Some("Piped"));
1244        assert_eq!(document.source.path, None);
1245    }
1246
1247    #[test]
1248    fn leading_tldr_directives_are_independent_from_the_markdown_document() {
1249        let source = "\
1250<!-- mant:tldr:start -->
1251# demo
1252
1253> Concise embedded help.
1254
1255- Run the demo:
1256
1257`demo {{path}}`
1258<!-- mant:tldr:end -->
1259
1260# Demo
1261
1262Document overview.
1263
1264## Options
1265
1266- `--help`: Show help.
1267";
1268        let result =
1269            query_markdown_text(source, Some("docs/demo.md".to_owned())).expect("Markdown query");
1270
1271        let tldr = result.tldr.expect("embedded tldr");
1272        assert_eq!(tldr.title, "demo");
1273        assert_eq!(tldr.origin, TldrOrigin::Embedded);
1274        assert_eq!(tldr.source_path, "docs/demo.md");
1275        assert_eq!(tldr.examples[0].command, "demo {{path}}");
1276
1277        let document = result.document.expect("document body");
1278        assert_eq!(document.meta.title.as_deref(), Some("Demo"));
1279        assert_eq!(document.sections[0].title, "Options");
1280        assert!(
1281            document
1282                .blocks
1283                .iter()
1284                .any(|block| matches!(block, mant_ast::Block::Paragraph { .. }))
1285        );
1286        assert!(
1287            document
1288                .diagnostics
1289                .iter()
1290                .all(|diagnostic| !diagnostic.message.contains("mant:tldr"))
1291        );
1292    }
1293
1294    #[test]
1295    fn malformed_leading_tldr_directives_report_the_source_path() {
1296        let error = query_markdown_text(
1297            "<!-- mant:tldr:start -->\n# demo\n\n- Run:\n\n`demo`\n",
1298            Some("docs/broken.md".to_owned()),
1299        )
1300        .expect_err("unterminated directive");
1301
1302        assert_eq!(
1303            error.to_string(),
1304            "could not load Markdown document 'docs/broken.md': top-level <!-- mant:tldr:start --> marker is missing its <!-- mant:tldr:end --> marker"
1305        );
1306    }
1307
1308    #[test]
1309    fn capped_read_accepts_input_up_to_the_limit() {
1310        let source = "abcd";
1311        assert_eq!(
1312            read_capped_utf8(source.as_bytes(), source.len() as u64).expect("within limit"),
1313            source
1314        );
1315    }
1316
1317    #[test]
1318    fn capped_read_rejects_input_past_the_limit_without_buffering_it_whole() {
1319        // An unbounded stream (modelled by io::repeat) must fail fast on the
1320        // limit rather than read forever, matching the /dev/zero guard.
1321        let error = read_capped_utf8(io::repeat(b'a'), 8).expect_err("over limit");
1322        assert!(error.contains("exceeds the 8-byte limit"), "{error}");
1323    }
1324
1325    #[test]
1326    fn capped_read_rejects_non_utf8_input() {
1327        let error =
1328            read_capped_utf8(&[0xff, 0xfe][..], MAX_MARKDOWN_BYTES).expect_err("invalid UTF-8");
1329        assert!(error.contains("must be UTF-8"), "{error}");
1330    }
1331
1332    #[test]
1333    fn capped_io_read_preserves_the_underlying_error_kind() {
1334        struct PermissionDeniedReader;
1335
1336        impl io::Read for PermissionDeniedReader {
1337            fn read(&mut self, _buffer: &mut [u8]) -> io::Result<usize> {
1338                Err(io::Error::new(
1339                    io::ErrorKind::PermissionDenied,
1340                    "reader denied access",
1341                ))
1342            }
1343        }
1344
1345        let error = read_capped_utf8_io(PermissionDeniedReader, MAX_MARKDOWN_BYTES)
1346            .expect_err("reader failure is preserved");
1347        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
1348        assert_eq!(error.to_string(), "reader denied access");
1349    }
1350}