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    // Explicit manual selection is exclusive: --manual and --section must not
617    // appear to resolve to tldr just because the quick reference is rendered
618    // before the requested page. Unqualified queries retain tldr as an
619    // optional augmentation and never update it during a read.
620    let tldr = if require_manual {
621        None
622    } else {
623        host.read_tldr(name).ok().flatten()
624    };
625    let mut manual = load_manual(name, &candidates, section.as_deref(), host);
626
627    // A malformed page may omit its own section metadata. Preserve the
628    // requested section so labels stay `name(N)`.
629    if let (Ok(document), Some(section)) = (&mut manual, section.as_deref())
630        && document.meta.section.is_none()
631    {
632        document.meta.section = Some(section.to_owned());
633    }
634
635    // An explicit manual request must not degrade into an apparently
636    // successful tldr-only response.
637    if require_manual {
638        return match manual {
639            Ok(manual) => Ok(QueryBundle {
640                schema: QuerySchema::V6,
641                label: name.to_owned(),
642                document: Some(manual),
643                tldr,
644            }),
645            Err(error) => Err(QueryError::Manual(error)),
646        };
647    }
648
649    match manual {
650        Ok(manual) => Ok(QueryBundle {
651            schema: QuerySchema::V6,
652            label: name.to_owned(),
653            document: Some(manual),
654            tldr,
655        }),
656        Err(_) if tldr.is_some() => Ok(QueryBundle {
657            schema: QuerySchema::V6,
658            label: name.to_owned(),
659            document: None,
660            tldr,
661        }),
662        Err(error) => Err(QueryError::Manual(error)),
663    }
664}
665
666fn query_registered_document(
667    name: &str,
668    path: &Path,
669    host: &dyn QueryHost,
670) -> Result<QueryBundle, QueryError> {
671    let source_path = path.to_string_lossy().into_owned();
672    let source = host
673        .read_markdown(path)
674        .map_err(|detail| QueryError::Markdown {
675            path: source_path.clone(),
676            detail,
677        })?;
678    let mut query = query_markdown_text(&source, Some(source_path))?;
679    name.clone_into(&mut query.label);
680    Ok(query)
681}
682
683fn load_manual(
684    requested_name: &str,
685    candidates: &[String],
686    section: Option<&str>,
687    host: &dyn QueryHost,
688) -> Result<MantDocument, ManualLoadError> {
689    let mut first_locate_error = None;
690    let mut located = None;
691    for candidate in candidates {
692        let request = ManualRequest::new(candidate, section.map(ToOwned::to_owned));
693        match host.locate_manual(&request) {
694            Ok(page) => {
695                located = Some(page);
696                break;
697            }
698            Err(error) => {
699                first_locate_error.get_or_insert(error);
700            }
701        }
702    }
703    let Some(page) = located else {
704        let error =
705            first_locate_error.unwrap_or_else(|| "no name candidates were available".to_owned());
706        return Err(ManualLoadError::NotFound {
707            name: requested_name.to_owned(),
708            detail: error,
709        });
710    };
711
712    let source_path = page.path.clone();
713    let document = host
714        .parse_manual(&page)
715        .map_err(|detail| ManualLoadError::Parse {
716            name: requested_name.to_owned(),
717            detail,
718        })?;
719    if document.sections.is_empty() {
720        let diagnostics = document
721            .diagnostics
722            .iter()
723            .map(|diagnostic| {
724                let location = diagnostic.source.map_or_else(String::new, |source| {
725                    format!(" at {}:{}", source.line, source.column)
726                });
727                format!("{:?}{location}: {}", diagnostic.level, diagnostic.message)
728            })
729            .collect::<Vec<_>>();
730        return Err(ManualLoadError::Empty {
731            name: requested_name.to_owned(),
732            path: source_path,
733            diagnostics,
734        });
735    }
736    Ok(document)
737}
738
739#[cfg(test)]
740mod tests {
741    use std::{
742        io,
743        path::{Path, PathBuf},
744        sync::Mutex,
745    };
746
747    use mant_ast::{
748        Diagnostic, DiagnosticLevel, DocumentMeta, DocumentSchema, DocumentSource, MantDocument,
749        Producer, QueryInput, QueryRequest, QueryView, RequestSchema, Section, SourceFormat,
750        TldrDocument, TldrOrigin,
751    };
752
753    use crate::{ManualPage, ManualRequest};
754
755    use super::{
756        MAX_MARKDOWN_BYTES, QueryError, QueryHost, QueryPolicy, query_markdown_text, query_with,
757        read_capped_utf8, read_capped_utf8_io,
758    };
759
760    #[derive(Clone)]
761    struct StubHost {
762        name_candidates: Option<Vec<String>>,
763        registered_document: Option<PathBuf>,
764        registered_name: Option<String>,
765        locate: Result<ManualPage, String>,
766        manual_name: Option<String>,
767        direct: Result<MantDocument, String>,
768        tldr: Result<Option<TldrDocument>, String>,
769        markdown: Result<String, String>,
770        calls: std::sync::Arc<Mutex<Vec<&'static str>>>,
771    }
772
773    impl QueryHost for StubHost {
774        fn name_candidates(&self, name: &str) -> Vec<String> {
775            self.name_candidates
776                .clone()
777                .unwrap_or_else(|| vec![name.to_owned()])
778        }
779
780        fn locate_registered_document(
781            &self,
782            candidates: &[String],
783            source: Option<&str>,
784        ) -> Result<Option<PathBuf>, String> {
785            self.calls
786                .lock()
787                .expect("calls lock")
788                .push(if source.is_some() { "source" } else { "name" });
789            if self
790                .registered_name
791                .as_deref()
792                .is_some_and(|registered_name| {
793                    !candidates
794                        .iter()
795                        .any(|candidate| candidate == registered_name)
796                })
797            {
798                return Ok(None);
799            }
800            Ok(self.registered_document.clone())
801        }
802
803        fn locate_manual(&self, request: &ManualRequest) -> Result<ManualPage, String> {
804            self.calls.lock().expect("calls lock").push("locate");
805            if self
806                .manual_name
807                .as_deref()
808                .is_some_and(|manual_name| manual_name != request.name)
809            {
810                return Err("source not found".to_owned());
811            }
812            self.locate.clone()
813        }
814
815        fn parse_manual(&self, _page: &ManualPage) -> Result<MantDocument, String> {
816            self.calls.lock().expect("calls lock").push("parse");
817            self.direct.clone()
818        }
819
820        fn read_tldr(&self, _name: &str) -> Result<Option<TldrDocument>, String> {
821            self.calls.lock().expect("calls lock").push("tldr");
822            self.tldr.clone()
823        }
824
825        fn read_markdown(&self, _path: &Path) -> Result<String, String> {
826            self.calls.lock().expect("calls lock").push("markdown");
827            self.markdown.clone()
828        }
829    }
830
831    fn document(format: SourceFormat, unsupported: bool, readable: bool) -> MantDocument {
832        MantDocument {
833            schema: DocumentSchema::V6,
834            producer: Producer {
835                name: "test".to_owned(),
836                version: "1".to_owned(),
837                engine: None,
838            },
839            source: DocumentSource { format, path: None },
840            meta: DocumentMeta::default(),
841            diagnostics: unsupported
842                .then_some(Diagnostic {
843                    level: DiagnosticLevel::Unsupported,
844                    code: None,
845                    message: "unsupported request".to_owned(),
846                    source: None,
847                })
848                .into_iter()
849                .collect(),
850            blocks: Vec::new(),
851            sections: readable
852                .then_some(Section {
853                    id: "name-1".to_owned(),
854                    title: "NAME".to_owned(),
855                    spacing_before_lines: 0,
856                    blocks: Vec::new(),
857                    children: Vec::new(),
858                    source: None,
859                })
860                .into_iter()
861                .collect(),
862        }
863    }
864
865    fn tldr() -> TldrDocument {
866        TldrDocument {
867            title: "tool".to_owned(),
868            description: vec!["quick reference".to_owned()],
869            more_information: None,
870            examples: Vec::new(),
871            platform: "common".to_owned(),
872            language: "en".to_owned(),
873            source_path: "/cache/pages/common/tool.md".to_owned(),
874            origin: TldrOrigin::TldrPages,
875        }
876    }
877
878    fn host(direct: Result<MantDocument, String>) -> StubHost {
879        StubHost {
880            name_candidates: None,
881            registered_document: None,
882            registered_name: None,
883            locate: Ok(ManualPage {
884                name: "tool".to_owned(),
885                section: "1".to_owned(),
886                path: PathBuf::from("/man/tool.1"),
887                manual_root: PathBuf::from("/man"),
888            }),
889            manual_name: None,
890            direct,
891            tldr: Ok(None),
892            markdown: Err("Markdown unavailable".to_owned()),
893            calls: std::sync::Arc::default(),
894        }
895    }
896
897    fn request() -> QueryRequest {
898        QueryRequest {
899            schema: RequestSchema::V6,
900            input: QueryInput::Document {
901                name: " tool ".to_owned(),
902                source: None,
903                section: None,
904            },
905            view: QueryView::Full {},
906        }
907    }
908
909    #[test]
910    fn ordinary_manual_uses_the_native_parser() {
911        let host = host(Ok(document(SourceFormat::Man, false, true)));
912        let result = query_with(&request(), QueryPolicy::default(), &host).expect("query");
913
914        assert_eq!(result.label, "tool");
915        assert_eq!(
916            result.document.expect("manual").source.format,
917            SourceFormat::Man
918        );
919        assert_eq!(
920            *host.calls.lock().expect("calls lock"),
921            ["name", "tldr", "locate", "parse"]
922        );
923    }
924
925    #[test]
926    fn requested_section_backfills_metadata_the_parser_left_empty() {
927        let mut host = host(Ok(document(SourceFormat::Man, false, true)));
928        host.tldr = Ok(Some(tldr()));
929        let request = QueryRequest {
930            schema: RequestSchema::V6,
931            input: QueryInput::Document {
932                name: "tool".to_owned(),
933                source: None,
934                section: Some("3".to_owned()),
935            },
936            view: QueryView::Full {},
937        };
938
939        let result = query_with(&request, QueryPolicy::default(), &host).expect("query");
940        assert_eq!(
941            result
942                .document
943                .as_ref()
944                .expect("manual")
945                .meta
946                .section
947                .as_deref(),
948            Some("3"),
949            "requested section must label output when the parser omits it"
950        );
951        assert!(result.tldr.is_none(), "an explicit section is manual-only");
952        assert_eq!(
953            *host.calls.lock().expect("calls lock"),
954            ["locate", "parse"],
955            "an explicit manual section bypasses Markdown and tldr"
956        );
957    }
958
959    #[test]
960    fn explicit_source_reads_only_registered_markdown() {
961        let mut host = host(Err("manual must not be read".to_owned()));
962        host.registered_document = Some(PathBuf::from("/documents/tool.md"));
963        host.markdown = Ok("# Tool\n\nSource body.\n".to_owned());
964        let request = QueryRequest {
965            schema: RequestSchema::V6,
966            input: QueryInput::Document {
967                name: "tool".to_owned(),
968                source: Some("team".to_owned()),
969                section: None,
970            },
971            view: QueryView::Full {},
972        };
973        let result = query_with(&request, QueryPolicy::default(), &host).expect("source query");
974        assert_eq!(
975            result.document.expect("Markdown").meta.title.as_deref(),
976            Some("Tool")
977        );
978        assert_eq!(
979            *host.calls.lock().expect("calls lock"),
980            ["source", "markdown"]
981        );
982    }
983
984    #[test]
985    fn complete_direct_document_survives_an_unsupported_finding() {
986        let host = host(Ok(document(SourceFormat::Man, true, true)));
987        let result = query_with(&request(), QueryPolicy::default(), &host).expect("query");
988
989        assert_eq!(
990            result.document.expect("manual").source.format,
991            SourceFormat::Man
992        );
993        assert_eq!(
994            *host.calls.lock().expect("calls lock"),
995            ["name", "tldr", "locate", "parse"]
996        );
997    }
998
999    #[test]
1000    fn manual_only_bypasses_registered_markdown() {
1001        let mut host = host(Ok(document(SourceFormat::Man, true, true)));
1002        host.registered_document = Some(PathBuf::from("/data/mant/tool.md"));
1003        host.markdown = Ok("# Registered".to_owned());
1004        host.tldr = Ok(Some(tldr()));
1005        let result = query_with(&request(), QueryPolicy { manual_only: true }, &host)
1006            .expect("manual-only query");
1007
1008        assert_eq!(
1009            result.document.as_ref().expect("manual").source.format,
1010            SourceFormat::Man
1011        );
1012        assert!(result.tldr.is_none(), "manual-only must not attach tldr");
1013        assert_eq!(
1014            *host.calls.lock().expect("calls lock"),
1015            ["locate", "parse"],
1016            "manual-only lookup must not inspect Markdown or tldr namespaces"
1017        );
1018    }
1019
1020    #[test]
1021    fn manual_only_failure_is_not_hidden_by_tldr() {
1022        let mut host = host(Ok(document(SourceFormat::Man, true, false)));
1023        host.tldr = Ok(Some(tldr()));
1024
1025        let error = query_with(&request(), QueryPolicy { manual_only: true }, &host)
1026            .expect_err("an optional tldr page must not hide native parser failure");
1027
1028        let QueryError::Manual(detail) = error else {
1029            panic!("expected the native parser diagnostic");
1030        };
1031        assert!(detail.to_string().contains("/man/tool.1"));
1032        assert!(
1033            detail
1034                .to_string()
1035                .contains("Unsupported: unsupported request")
1036        );
1037        assert_eq!(*host.calls.lock().expect("calls lock"), ["locate", "parse"]);
1038    }
1039
1040    #[test]
1041    fn requested_section_failure_is_not_hidden_by_tldr() {
1042        let mut host = host(Err("libmandoc failed".to_owned()));
1043        host.locate = Err("section not found".to_owned());
1044        host.tldr = Ok(Some(tldr()));
1045        let request = QueryRequest {
1046            schema: RequestSchema::V6,
1047            input: QueryInput::Document {
1048                name: "tool".to_owned(),
1049                source: None,
1050                section: Some("7".to_owned()),
1051            },
1052            view: QueryView::Full {},
1053        };
1054
1055        let error = query_with(&request, QueryPolicy::default(), &host)
1056            .expect_err("an explicit section must require a native manual");
1057
1058        assert!(matches!(&error, QueryError::Manual(_)));
1059        assert!(error.to_string().contains("section not found"));
1060        assert_eq!(*host.calls.lock().expect("calls lock"), ["locate"]);
1061    }
1062
1063    #[test]
1064    fn truncated_unsupported_document_is_an_error_by_default() {
1065        let host = host(Ok(document(SourceFormat::Man, true, false)));
1066
1067        let QueryError::Manual(detail) = query_with(&request(), QueryPolicy::default(), &host)
1068            .expect_err("empty-section document must error by default")
1069        else {
1070            panic!("expected Manual error");
1071        };
1072        assert!(detail.to_string().contains("produced no readable sections"));
1073    }
1074
1075    #[test]
1076    fn readable_best_effort_document_survives_parser_findings() {
1077        let host = host(Ok(document(SourceFormat::Mdoc, true, true)));
1078        let result = query_with(&request(), QueryPolicy::default(), &host).expect("query");
1079        assert_eq!(
1080            result.document.expect("manual").source.format,
1081            SourceFormat::Mdoc
1082        );
1083    }
1084
1085    #[test]
1086    fn cached_tldr_survives_total_manual_failure() {
1087        let mut host = host(Err("libmandoc failed".to_owned()));
1088        host.locate = Err("source not found".to_owned());
1089        host.tldr = Ok(Some(tldr()));
1090        let result =
1091            query_with(&request(), QueryPolicy::default(), &host).expect("tldr-only query");
1092
1093        assert!(result.document.is_none());
1094        assert_eq!(result.tldr.expect("tldr").title, "tool");
1095    }
1096
1097    #[test]
1098    fn reports_both_manual_paths_when_no_content_exists() {
1099        let mut host = host(Err("libmandoc failed".to_owned()));
1100        host.locate = Err("source not found".to_owned());
1101        let error = query_with(&request(), QueryPolicy::default(), &host)
1102            .expect_err("empty query must fail");
1103        assert_eq!(
1104            error.to_string(),
1105            "could not load manual 'tool': manual source: source not found"
1106        );
1107    }
1108
1109    #[test]
1110    fn validates_before_touching_host_state() {
1111        let host = host(Ok(document(SourceFormat::Man, false, true)));
1112        assert_eq!(
1113            query_with(
1114                &QueryRequest {
1115                    schema: RequestSchema::V6,
1116                    input: QueryInput::Document {
1117                        name: " ".to_owned(),
1118                        source: None,
1119                        section: None,
1120                    },
1121                    view: QueryView::Full {},
1122                },
1123                QueryPolicy::default(),
1124                &host
1125            ),
1126            Err(QueryError::EmptyName)
1127        );
1128        assert!(host.calls.lock().expect("calls lock").is_empty());
1129    }
1130
1131    #[test]
1132    fn registered_markdown_shadows_an_unqualified_manual_name() {
1133        let mut host = host(Err("manual parser must not run".to_owned()));
1134        host.registered_document = Some(PathBuf::from("/data/mant/tool.md"));
1135        host.markdown = Ok("# Tool\n\n## Options\n\n- `--help`: Show help.\n".to_owned());
1136
1137        let result = query_with(&request(), QueryPolicy::default(), &host)
1138            .expect("registered Markdown name");
1139
1140        assert_eq!(result.label, "tool");
1141        assert!(result.tldr.is_none());
1142        let document = result.document.expect("registered document");
1143        assert_eq!(document.source.format, SourceFormat::Markdown);
1144        assert_eq!(document.source.path.as_deref(), Some("/data/mant/tool.md"));
1145        assert_eq!(
1146            *host.calls.lock().expect("calls lock"),
1147            ["name", "markdown"],
1148            "a registered name must not consult man or external tldr caches"
1149        );
1150    }
1151
1152    #[test]
1153    fn windows_suffix_fallback_can_resolve_registered_markdown() {
1154        let mut host = host(Err("manual parser must not run".to_owned()));
1155        host.name_candidates = Some(vec!["tool".to_owned(), "tool.EXE".to_owned()]);
1156        host.registered_name = Some("tool.EXE".to_owned());
1157        host.registered_document = Some(PathBuf::from("/data/mant/tool.exe.md"));
1158        host.markdown = Ok("# Tool executable\n\nWindows command documentation.\n".to_owned());
1159
1160        let result = query_with(&request(), QueryPolicy::default(), &host)
1161            .expect("registered executable document");
1162
1163        assert_eq!(result.label, "tool");
1164        assert_eq!(
1165            result.document.expect("document").source.path.as_deref(),
1166            Some("/data/mant/tool.exe.md")
1167        );
1168        assert_eq!(
1169            *host.calls.lock().expect("calls lock"),
1170            ["name", "markdown"]
1171        );
1172    }
1173
1174    #[test]
1175    fn windows_suffix_fallback_can_resolve_a_native_manual() {
1176        let mut host = host(Ok(document(SourceFormat::Man, false, true)));
1177        host.name_candidates = Some(vec!["tool".to_owned(), "tool.EXE".to_owned()]);
1178        host.manual_name = Some("tool.EXE".to_owned());
1179        host.locate = Ok(ManualPage {
1180            name: "tool.exe".to_owned(),
1181            section: "1".to_owned(),
1182            path: PathBuf::from("/man/tool.exe.1"),
1183            manual_root: PathBuf::from("/man"),
1184        });
1185
1186        let result = query_with(&request(), QueryPolicy::default(), &host)
1187            .expect("native executable manual");
1188
1189        assert_eq!(result.label, "tool");
1190        assert_eq!(
1191            result.document.expect("manual").source.format,
1192            SourceFormat::Man
1193        );
1194        assert_eq!(
1195            *host.calls.lock().expect("calls lock"),
1196            ["name", "tldr", "locate", "locate", "parse"]
1197        );
1198    }
1199
1200    #[test]
1201    fn exact_names_win_before_windows_suffix_fallback() {
1202        let mut host = host(Err("manual parser must not run".to_owned()));
1203        host.name_candidates = Some(vec!["tool".to_owned(), "tool.EXE".to_owned()]);
1204        host.registered_document = Some(PathBuf::from("/data/mant/tool.md"));
1205        host.markdown = Ok("# Exact tool\n\nExact-name documentation.\n".to_owned());
1206
1207        let result = query_with(&request(), QueryPolicy::default(), &host)
1208            .expect("exact registered document");
1209
1210        assert_eq!(
1211            result.document.expect("document").source.path.as_deref(),
1212            Some("/data/mant/tool.md")
1213        );
1214        assert_eq!(
1215            *host.calls.lock().expect("calls lock"),
1216            ["name", "markdown"]
1217        );
1218    }
1219
1220    #[test]
1221    fn markdown_files_bypass_manual_and_tldr_sources() {
1222        let mut host = host(Err("manual parser must not run".to_owned()));
1223        host.markdown = Ok("# Tool\n\n## Options\n\n- `--help`: Show help.\n".to_owned());
1224        let result = query_with(
1225            &QueryRequest {
1226                schema: RequestSchema::V6,
1227                input: QueryInput::MarkdownFile {
1228                    path: "docs/tool.md".to_owned(),
1229                },
1230                view: QueryView::Full {},
1231            },
1232            QueryPolicy::default(),
1233            &host,
1234        )
1235        .expect("Markdown query");
1236
1237        assert_eq!(result.label, "tool.md");
1238        assert!(result.tldr.is_none());
1239        let document = result.document.expect("document");
1240        assert_eq!(document.source.format, SourceFormat::Markdown);
1241        assert_eq!(document.source.path.as_deref(), Some("docs/tool.md"));
1242        assert_eq!(
1243            *host.calls.lock().expect("calls lock"),
1244            ["markdown"],
1245            "Markdown must not consult man or tldr"
1246        );
1247    }
1248
1249    #[test]
1250    fn in_memory_markdown_is_available_without_a_protocol_content_field() {
1251        let result = query_markdown_text("# Piped\n\nBody.\n", None).expect("stdin Markdown query");
1252
1253        assert_eq!(result.label, "stdin");
1254        assert!(result.tldr.is_none());
1255        let document = result.document.expect("document");
1256        assert_eq!(document.meta.title.as_deref(), Some("Piped"));
1257        assert_eq!(document.source.path, None);
1258    }
1259
1260    #[test]
1261    fn leading_tldr_directives_are_independent_from_the_markdown_document() {
1262        let source = "\
1263<!-- mant:tldr:start -->
1264# demo
1265
1266> Concise embedded help.
1267
1268- Run the demo:
1269
1270`demo {{path}}`
1271<!-- mant:tldr:end -->
1272
1273# Demo
1274
1275Document overview.
1276
1277## Options
1278
1279- `--help`: Show help.
1280";
1281        let result =
1282            query_markdown_text(source, Some("docs/demo.md".to_owned())).expect("Markdown query");
1283
1284        let tldr = result.tldr.expect("embedded tldr");
1285        assert_eq!(tldr.title, "demo");
1286        assert_eq!(tldr.origin, TldrOrigin::Embedded);
1287        assert_eq!(tldr.source_path, "docs/demo.md");
1288        assert_eq!(tldr.examples[0].command, "demo {{path}}");
1289
1290        let document = result.document.expect("document body");
1291        assert_eq!(document.meta.title.as_deref(), Some("Demo"));
1292        assert_eq!(document.sections[0].title, "Options");
1293        assert!(
1294            document
1295                .blocks
1296                .iter()
1297                .any(|block| matches!(block, mant_ast::Block::Paragraph { .. }))
1298        );
1299        assert!(
1300            document
1301                .diagnostics
1302                .iter()
1303                .all(|diagnostic| !diagnostic.message.contains("mant:tldr"))
1304        );
1305    }
1306
1307    #[test]
1308    fn malformed_leading_tldr_directives_report_the_source_path() {
1309        let error = query_markdown_text(
1310            "<!-- mant:tldr:start -->\n# demo\n\n- Run:\n\n`demo`\n",
1311            Some("docs/broken.md".to_owned()),
1312        )
1313        .expect_err("unterminated directive");
1314
1315        assert_eq!(
1316            error.to_string(),
1317            "could not load Markdown document 'docs/broken.md': top-level <!-- mant:tldr:start --> marker is missing its <!-- mant:tldr:end --> marker"
1318        );
1319    }
1320
1321    #[test]
1322    fn capped_read_accepts_input_up_to_the_limit() {
1323        let source = "abcd";
1324        assert_eq!(
1325            read_capped_utf8(source.as_bytes(), source.len() as u64).expect("within limit"),
1326            source
1327        );
1328    }
1329
1330    #[test]
1331    fn capped_read_rejects_input_past_the_limit_without_buffering_it_whole() {
1332        // An unbounded stream (modelled by io::repeat) must fail fast on the
1333        // limit rather than read forever, matching the /dev/zero guard.
1334        let error = read_capped_utf8(io::repeat(b'a'), 8).expect_err("over limit");
1335        assert!(error.contains("exceeds the 8-byte limit"), "{error}");
1336    }
1337
1338    #[test]
1339    fn capped_read_rejects_non_utf8_input() {
1340        let error =
1341            read_capped_utf8(&[0xff, 0xfe][..], MAX_MARKDOWN_BYTES).expect_err("invalid UTF-8");
1342        assert!(error.contains("must be UTF-8"), "{error}");
1343    }
1344
1345    #[test]
1346    fn capped_io_read_preserves_the_underlying_error_kind() {
1347        struct PermissionDeniedReader;
1348
1349        impl io::Read for PermissionDeniedReader {
1350            fn read(&mut self, _buffer: &mut [u8]) -> io::Result<usize> {
1351                Err(io::Error::new(
1352                    io::ErrorKind::PermissionDenied,
1353                    "reader denied access",
1354                ))
1355            }
1356        }
1357
1358        let error = read_capped_utf8_io(PermissionDeniedReader, MAX_MARKDOWN_BYTES)
1359            .expect_err("reader failure is preserved");
1360        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
1361        assert_eq!(error.to_string(), "reader denied access");
1362    }
1363}