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::Read,
8    path::{Path, PathBuf},
9};
10
11use mant_ast::{MantDocument, QueryBundle, QueryInput, QueryRequest, QuerySchema, TldrDocument};
12
13use crate::{ManualRequest, find_registered_document, parse_markdown, read_cached_tldr_page};
14
15#[cfg(unix)]
16use crate::{locate_manual_source, parse_manual_source};
17
18/// Upper bound on a single Markdown source, shared by every input path.
19///
20/// File and stdin readers both enforce this so an unbounded source (a pipe, a
21/// character device such as `/dev/zero`, or a pathologically large file) cannot
22/// exhaust memory. A file's reported length is not trusted: some sources report
23/// zero yet stream without end, so readers cap the byte count directly.
24pub const MAX_MARKDOWN_BYTES: u64 = 16 * 1024 * 1024;
25
26/// A query cannot produce either authoritative manual content or a quick reference.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum QueryError {
29    EmptyName,
30    InvalidSection,
31    EmptyMarkdownPath,
32    Markdown { path: String, detail: String },
33    EmptyMarkdown { label: String },
34    Manual { name: String, detail: String },
35    NoReadableContent { name: String },
36}
37
38/// Input-resolution policy kept outside the serialized request contract.
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
40pub struct QueryPolicy {
41    /// Bypass registered Markdown and require a readable native manual.
42    pub manual_only: bool,
43}
44
45impl fmt::Display for QueryError {
46    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
47        match self {
48            Self::EmptyName => formatter.write_str("name must not be empty"),
49            Self::InvalidSection => formatter.write_str("manual section must not be empty"),
50            Self::EmptyMarkdownPath => formatter.write_str("Markdown path must not be empty"),
51            Self::Markdown { path, detail } => {
52                write!(
53                    formatter,
54                    "could not load Markdown document '{path}': {detail}"
55                )
56            }
57            Self::EmptyMarkdown { label } => {
58                write!(
59                    formatter,
60                    "Markdown document '{label}' has no readable content"
61                )
62            }
63            Self::Manual { detail, .. } => formatter.write_str(detail),
64            Self::NoReadableContent { name } => {
65                write!(
66                    formatter,
67                    "no readable document content was found for '{name}'"
68                )
69            }
70        }
71    }
72}
73
74impl Error for QueryError {}
75
76/// Query the local man database and optional offline tldr caches.
77///
78/// # Errors
79///
80/// Returns [`QueryError`] for invalid input or when neither source can produce
81/// readable content.
82pub fn query(request: &QueryRequest) -> Result<QueryBundle, QueryError> {
83    query_with(request, QueryPolicy::default(), &SystemQueryHost)
84}
85
86/// Query with an explicit input-resolution policy.
87///
88/// # Errors
89///
90/// Returns [`QueryError`] under the same conditions as [`query`].
91pub fn query_with_policy(
92    request: &QueryRequest,
93    policy: QueryPolicy,
94) -> Result<QueryBundle, QueryError> {
95    query_with(request, policy, &SystemQueryHost)
96}
97
98trait QueryHost {
99    fn locate_registered_document(&self, name: &str) -> Option<PathBuf>;
100    fn locate_manual(&self, request: &ManualRequest) -> Result<PathBuf, String>;
101    fn parse_manual(&self, path: &Path) -> Result<MantDocument, String>;
102    fn read_tldr(&self, name: &str) -> Result<Option<TldrDocument>, String>;
103    fn read_markdown(&self, path: &Path) -> Result<String, String>;
104}
105
106struct SystemQueryHost;
107
108impl QueryHost for SystemQueryHost {
109    fn locate_registered_document(&self, name: &str) -> Option<PathBuf> {
110        find_registered_document(name).map(|registered| registered.path)
111    }
112
113    fn locate_manual(&self, request: &ManualRequest) -> Result<PathBuf, String> {
114        #[cfg(unix)]
115        {
116            locate_manual_source(request).map_err(|error| error.to_string())
117        }
118        #[cfg(not(unix))]
119        {
120            Err(format!(
121                "native manual pages are unavailable on this platform; register a Markdown document named '{}'",
122                request.name
123            ))
124        }
125    }
126
127    fn parse_manual(&self, path: &Path) -> Result<MantDocument, String> {
128        #[cfg(unix)]
129        {
130            parse_manual_source(path).map_err(|error| error.to_string())
131        }
132        #[cfg(not(unix))]
133        {
134            Err(format!(
135                "native manual parsing is unavailable on this platform: {}",
136                path.display()
137            ))
138        }
139    }
140
141    fn read_tldr(&self, name: &str) -> Result<Option<TldrDocument>, String> {
142        read_cached_tldr_page(name).map_err(|error| error.to_string())
143    }
144
145    fn read_markdown(&self, path: &Path) -> Result<String, String> {
146        let file = fs::File::open(path).map_err(|error| error.to_string())?;
147        read_capped_utf8(file, MAX_MARKDOWN_BYTES)
148    }
149}
150
151/// Read at most `limit` bytes of UTF-8, rejecting anything larger.
152///
153/// The reader is bounded directly instead of trusting a reported length: a pipe
154/// or character device such as `/dev/zero` reports no size yet streams without
155/// end, so only capping the byte count keeps the read finite.
156fn read_capped_utf8(reader: impl Read, limit: u64) -> Result<String, String> {
157    let mut bytes = Vec::new();
158    reader
159        .take(limit + 1)
160        .read_to_end(&mut bytes)
161        .map_err(|error| error.to_string())?;
162    if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > limit {
163        return Err(format!("Markdown document exceeds the {limit}-byte limit"));
164    }
165    String::from_utf8(bytes).map_err(|_| "Markdown document must be UTF-8".to_owned())
166}
167
168fn query_with(
169    request: &QueryRequest,
170    policy: QueryPolicy,
171    host: &dyn QueryHost,
172) -> Result<QueryBundle, QueryError> {
173    match &request.input {
174        QueryInput::Document { name, section } => {
175            query_manual(name, section.as_deref(), policy, host)
176        }
177        QueryInput::MarkdownFile { path } => query_markdown_file(path, policy, host),
178    }
179}
180
181fn query_markdown_file(
182    requested_path: &str,
183    policy: QueryPolicy,
184    host: &dyn QueryHost,
185) -> Result<QueryBundle, QueryError> {
186    let path = requested_path.trim();
187    if path.is_empty() {
188        return Err(QueryError::EmptyMarkdownPath);
189    }
190    if policy.manual_only {
191        return Err(QueryError::Markdown {
192            path: path.to_owned(),
193            detail: "the manual-only policy does not apply to Markdown input".to_owned(),
194        });
195    }
196    let source = host
197        .read_markdown(Path::new(path))
198        .map_err(|detail| QueryError::Markdown {
199            path: path.to_owned(),
200            detail,
201        })?;
202    query_markdown_text(&source, Some(path.to_owned()))
203}
204
205/// Parse in-memory Markdown for the direct `mant -` command.
206///
207/// This helper intentionally sits outside [`QueryRequest`]: public protocol
208/// requests reference local files and never embed arbitrary document content.
209///
210/// # Errors
211///
212/// Returns [`QueryError::EmptyMarkdown`] when parsing yields no visible blocks
213/// or sections.
214pub fn query_markdown_text(
215    source: &str,
216    source_path: Option<String>,
217) -> Result<QueryBundle, QueryError> {
218    let label = source_path.as_deref().map_or_else(
219        || "stdin".to_owned(),
220        |path| {
221            Path::new(path)
222                .file_name()
223                .and_then(OsStr::to_str)
224                .unwrap_or(path)
225                .to_owned()
226        },
227    );
228    let error_path = source_path.clone().unwrap_or_else(|| "stdin".to_owned());
229    let parsed = parse_markdown(source, source_path).map_err(|error| QueryError::Markdown {
230        path: error_path,
231        detail: error.to_string(),
232    })?;
233    let document_is_empty =
234        parsed.document.blocks.is_empty() && parsed.document.sections.is_empty();
235    if document_is_empty && parsed.tldr.is_none() {
236        return Err(QueryError::EmptyMarkdown {
237            label: label.clone(),
238        });
239    }
240    Ok(QueryBundle {
241        schema: QuerySchema::V4,
242        label,
243        document: (!document_is_empty).then_some(parsed.document),
244        tldr: parsed.tldr,
245    })
246}
247
248fn query_manual(
249    name: &str,
250    requested_section: Option<&str>,
251    policy: QueryPolicy,
252    host: &dyn QueryHost,
253) -> Result<QueryBundle, QueryError> {
254    let name = name.trim();
255    if name.is_empty() {
256        return Err(QueryError::EmptyName);
257    }
258    let section = requested_section.map(str::trim);
259    if section.is_some_and(str::is_empty) {
260        return Err(QueryError::InvalidSection);
261    }
262    let section = section.map(ToOwned::to_owned);
263    let require_manual = policy.manual_only || section.is_some();
264
265    // An unqualified name first consults the platform-native registration
266    // namespace. Section selectors and the explicit manual-only policy bypass
267    // Markdown name discovery.
268    if section.is_none()
269        && !policy.manual_only
270        && let Some(path) = host.locate_registered_document(name)
271    {
272        return query_registered_document(name, &path, host);
273    }
274
275    let manual_request = ManualRequest::new(name, section.clone());
276
277    // A malformed or unreadable community cache must never hide a valid man
278    // page. It is an optional augmentation and is never updated during query.
279    let tldr = host.read_tldr(name).ok().flatten();
280    let mut manual = load_manual(&manual_request, host);
281
282    // A malformed page may omit its own section metadata. Preserve the
283    // requested section so labels stay `name(N)`.
284    if let (Ok(Some(document)), Some(section)) = (&mut manual, section.as_deref())
285        && document.meta.section.is_none()
286    {
287        document.meta.section = Some(section.to_owned());
288    }
289
290    // An explicit manual request may include tldr beside a successful manual,
291    // but must not degrade into an apparently successful tldr-only response.
292    if require_manual {
293        return match manual {
294            Ok(Some(manual)) => Ok(QueryBundle {
295                schema: QuerySchema::V4,
296                label: name.to_owned(),
297                document: Some(manual),
298                tldr,
299            }),
300            Ok(None) => Err(QueryError::NoReadableContent {
301                name: name.to_owned(),
302            }),
303            Err(detail) => Err(QueryError::Manual {
304                name: name.to_owned(),
305                detail,
306            }),
307        };
308    }
309
310    match manual {
311        Ok(Some(manual)) => Ok(QueryBundle {
312            schema: QuerySchema::V4,
313            label: name.to_owned(),
314            document: Some(manual),
315            tldr,
316        }),
317        Ok(None) | Err(_) if tldr.is_some() => Ok(QueryBundle {
318            schema: QuerySchema::V4,
319            label: name.to_owned(),
320            document: None,
321            tldr,
322        }),
323        Ok(None) => Err(QueryError::NoReadableContent {
324            name: name.to_owned(),
325        }),
326        Err(detail) => Err(QueryError::Manual {
327            name: name.to_owned(),
328            detail,
329        }),
330    }
331}
332
333fn query_registered_document(
334    name: &str,
335    path: &Path,
336    host: &dyn QueryHost,
337) -> Result<QueryBundle, QueryError> {
338    let source_path = path.to_string_lossy().into_owned();
339    let source = host
340        .read_markdown(path)
341        .map_err(|detail| QueryError::Markdown {
342            path: source_path.clone(),
343            detail,
344        })?;
345    let mut query = query_markdown_text(&source, Some(source_path))?;
346    name.clone_into(&mut query.label);
347    Ok(query)
348}
349
350fn load_manual(
351    request: &ManualRequest,
352    host: &dyn QueryHost,
353) -> Result<Option<MantDocument>, String> {
354    let located = host.locate_manual(request);
355    let (source_path, direct) = match located {
356        Ok(path) => {
357            let direct = host.parse_manual(&path);
358            (Some(path), direct)
359        }
360        Err(error) => (None, Err(error)),
361    };
362
363    let document = direct.map_err(|error| {
364        format!(
365            "could not load manual '{}': source/libmandoc: {error}",
366            request.name
367        )
368    })?;
369    if document.sections.is_empty() {
370        let path = source_path.as_deref().map_or_else(
371            || "<unknown source>".to_owned(),
372            |path| path.display().to_string(),
373        );
374        let diagnostics = document
375            .diagnostics
376            .iter()
377            .map(|diagnostic| {
378                let location = diagnostic.source.map_or_else(String::new, |source| {
379                    format!(" at {}:{}", source.line, source.column)
380                });
381                format!("{:?}{location}: {}", diagnostic.level, diagnostic.message)
382            })
383            .collect::<Vec<_>>()
384            .join("; ");
385        let detail = if diagnostics.is_empty() {
386            String::new()
387        } else {
388            format!("; diagnostics: {diagnostics}")
389        };
390        return Err(format!(
391            "could not load manual '{}': libmandoc parsed {path} but produced no readable sections{detail}",
392            request.name,
393        ));
394    }
395    Ok(Some(document))
396}
397
398#[cfg(test)]
399mod tests {
400    use std::{
401        io,
402        path::{Path, PathBuf},
403        sync::Mutex,
404    };
405
406    use mant_ast::{
407        Diagnostic, DiagnosticLevel, DocumentMeta, DocumentSchema, DocumentSource, MantDocument,
408        Producer, QueryInput, QueryRequest, QueryView, RequestSchema, Section, SourceFormat,
409        TldrDocument, TldrOrigin,
410    };
411
412    use crate::ManualRequest;
413
414    use super::{
415        MAX_MARKDOWN_BYTES, QueryError, QueryHost, QueryPolicy, query_markdown_text, query_with,
416        read_capped_utf8,
417    };
418
419    #[derive(Clone)]
420    struct StubHost {
421        registered_document: Option<PathBuf>,
422        locate: Result<PathBuf, String>,
423        direct: Result<MantDocument, String>,
424        tldr: Result<Option<TldrDocument>, String>,
425        markdown: Result<String, String>,
426        calls: std::sync::Arc<Mutex<Vec<&'static str>>>,
427    }
428
429    impl QueryHost for StubHost {
430        fn locate_registered_document(&self, _name: &str) -> Option<PathBuf> {
431            self.calls.lock().expect("calls lock").push("name");
432            self.registered_document.clone()
433        }
434
435        fn locate_manual(&self, _request: &ManualRequest) -> Result<PathBuf, String> {
436            self.calls.lock().expect("calls lock").push("locate");
437            self.locate.clone()
438        }
439
440        fn parse_manual(&self, _path: &Path) -> Result<MantDocument, String> {
441            self.calls.lock().expect("calls lock").push("parse");
442            self.direct.clone()
443        }
444
445        fn read_tldr(&self, _name: &str) -> Result<Option<TldrDocument>, String> {
446            self.calls.lock().expect("calls lock").push("tldr");
447            self.tldr.clone()
448        }
449
450        fn read_markdown(&self, _path: &Path) -> Result<String, String> {
451            self.calls.lock().expect("calls lock").push("markdown");
452            self.markdown.clone()
453        }
454    }
455
456    fn document(format: SourceFormat, unsupported: bool, readable: bool) -> MantDocument {
457        MantDocument {
458            schema: DocumentSchema::V4,
459            producer: Producer {
460                name: "test".to_owned(),
461                version: "1".to_owned(),
462                engine: None,
463            },
464            source: DocumentSource { format, path: None },
465            meta: DocumentMeta::default(),
466            diagnostics: unsupported
467                .then_some(Diagnostic {
468                    level: DiagnosticLevel::Unsupported,
469                    code: None,
470                    message: "unsupported request".to_owned(),
471                    source: None,
472                })
473                .into_iter()
474                .collect(),
475            blocks: Vec::new(),
476            sections: readable
477                .then_some(Section {
478                    id: "name-1".to_owned(),
479                    title: "NAME".to_owned(),
480                    spacing_before_lines: 0,
481                    blocks: Vec::new(),
482                    children: Vec::new(),
483                    source: None,
484                })
485                .into_iter()
486                .collect(),
487        }
488    }
489
490    fn tldr() -> TldrDocument {
491        TldrDocument {
492            title: "tool".to_owned(),
493            description: vec!["quick reference".to_owned()],
494            more_information: None,
495            examples: Vec::new(),
496            platform: "common".to_owned(),
497            language: "en".to_owned(),
498            source_path: "/cache/pages/common/tool.md".to_owned(),
499            origin: TldrOrigin::TldrPages,
500        }
501    }
502
503    fn host(direct: Result<MantDocument, String>) -> StubHost {
504        StubHost {
505            registered_document: None,
506            locate: Ok(PathBuf::from("/man/tool.1")),
507            direct,
508            tldr: Ok(None),
509            markdown: Err("Markdown unavailable".to_owned()),
510            calls: std::sync::Arc::default(),
511        }
512    }
513
514    fn request() -> QueryRequest {
515        QueryRequest {
516            schema: RequestSchema::V4,
517            input: QueryInput::Document {
518                name: " tool ".to_owned(),
519                section: None,
520            },
521            view: QueryView::Full {},
522        }
523    }
524
525    #[test]
526    fn ordinary_manual_uses_the_native_parser() {
527        let host = host(Ok(document(SourceFormat::Man, false, true)));
528        let result = query_with(&request(), QueryPolicy::default(), &host).expect("query");
529
530        assert_eq!(result.label, "tool");
531        assert_eq!(
532            result.document.expect("manual").source.format,
533            SourceFormat::Man
534        );
535        assert_eq!(
536            *host.calls.lock().expect("calls lock"),
537            ["name", "tldr", "locate", "parse"]
538        );
539    }
540
541    #[test]
542    fn requested_section_backfills_metadata_the_parser_left_empty() {
543        let host = host(Ok(document(SourceFormat::Man, false, true)));
544        let request = QueryRequest {
545            schema: RequestSchema::V4,
546            input: QueryInput::Document {
547                name: "tool".to_owned(),
548                section: Some("3".to_owned()),
549            },
550            view: QueryView::Full {},
551        };
552
553        let result = query_with(&request, QueryPolicy::default(), &host).expect("query");
554        assert_eq!(
555            result.document.expect("manual").meta.section.as_deref(),
556            Some("3"),
557            "requested section must label output when the parser omits it"
558        );
559        assert_eq!(
560            *host.calls.lock().expect("calls lock"),
561            ["tldr", "locate", "parse"],
562            "an explicit manual section bypasses registered Markdown"
563        );
564    }
565
566    #[test]
567    fn complete_direct_document_survives_an_unsupported_finding() {
568        let host = host(Ok(document(SourceFormat::Man, true, true)));
569        let result = query_with(&request(), QueryPolicy::default(), &host).expect("query");
570
571        assert_eq!(
572            result.document.expect("manual").source.format,
573            SourceFormat::Man
574        );
575        assert_eq!(
576            *host.calls.lock().expect("calls lock"),
577            ["name", "tldr", "locate", "parse"]
578        );
579    }
580
581    #[test]
582    fn manual_only_bypasses_registered_markdown() {
583        let mut host = host(Ok(document(SourceFormat::Man, true, true)));
584        host.registered_document = Some(PathBuf::from("/data/mant/tool.md"));
585        host.markdown = Ok("# Registered".to_owned());
586        let result = query_with(&request(), QueryPolicy { manual_only: true }, &host)
587            .expect("manual-only query");
588
589        assert_eq!(
590            result.document.expect("manual").source.format,
591            SourceFormat::Man
592        );
593        assert_eq!(
594            *host.calls.lock().expect("calls lock"),
595            ["tldr", "locate", "parse"],
596            "manual-only lookup must not inspect the registered-document namespace"
597        );
598    }
599
600    #[test]
601    fn manual_only_failure_is_not_hidden_by_tldr() {
602        let mut host = host(Ok(document(SourceFormat::Man, true, false)));
603        host.tldr = Ok(Some(tldr()));
604
605        let error = query_with(&request(), QueryPolicy { manual_only: true }, &host)
606            .expect_err("an optional tldr page must not hide native parser failure");
607
608        let QueryError::Manual { detail, .. } = error else {
609            panic!("expected the native parser diagnostic");
610        };
611        assert!(detail.contains("/man/tool.1"));
612        assert!(detail.contains("Unsupported: unsupported request"));
613        assert_eq!(
614            *host.calls.lock().expect("calls lock"),
615            ["tldr", "locate", "parse"]
616        );
617    }
618
619    #[test]
620    fn requested_section_failure_is_not_hidden_by_tldr() {
621        let mut host = host(Err("libmandoc failed".to_owned()));
622        host.locate = Err("section not found".to_owned());
623        host.tldr = Ok(Some(tldr()));
624        let request = QueryRequest {
625            schema: RequestSchema::V4,
626            input: QueryInput::Document {
627                name: "tool".to_owned(),
628                section: Some("7".to_owned()),
629            },
630            view: QueryView::Full {},
631        };
632
633        let error = query_with(&request, QueryPolicy::default(), &host)
634            .expect_err("an explicit section must require a native manual");
635
636        assert!(matches!(&error, QueryError::Manual { .. }));
637        assert!(error.to_string().contains("section not found"));
638        assert_eq!(*host.calls.lock().expect("calls lock"), ["tldr", "locate"]);
639    }
640
641    #[test]
642    fn truncated_unsupported_document_is_an_error_by_default() {
643        let host = host(Ok(document(SourceFormat::Man, true, false)));
644
645        let QueryError::Manual { detail, .. } =
646            query_with(&request(), QueryPolicy::default(), &host)
647                .expect_err("empty-section document must error by default")
648        else {
649            panic!("expected Manual error");
650        };
651        assert!(detail.contains("produced no readable sections"));
652    }
653
654    #[test]
655    fn readable_best_effort_document_survives_parser_findings() {
656        let host = host(Ok(document(SourceFormat::Mdoc, true, true)));
657        let result = query_with(&request(), QueryPolicy::default(), &host).expect("query");
658        assert_eq!(
659            result.document.expect("manual").source.format,
660            SourceFormat::Mdoc
661        );
662    }
663
664    #[test]
665    fn cached_tldr_survives_total_manual_failure() {
666        let mut host = host(Err("libmandoc failed".to_owned()));
667        host.locate = Err("source not found".to_owned());
668        host.tldr = Ok(Some(tldr()));
669        let result =
670            query_with(&request(), QueryPolicy::default(), &host).expect("tldr-only query");
671
672        assert!(result.document.is_none());
673        assert_eq!(result.tldr.expect("tldr").title, "tool");
674    }
675
676    #[test]
677    fn reports_both_manual_paths_when_no_content_exists() {
678        let mut host = host(Err("libmandoc failed".to_owned()));
679        host.locate = Err("source not found".to_owned());
680        let error = query_with(&request(), QueryPolicy::default(), &host)
681            .expect_err("empty query must fail");
682        assert_eq!(
683            error.to_string(),
684            "could not load manual 'tool': source/libmandoc: source not found"
685        );
686    }
687
688    #[test]
689    fn validates_before_touching_host_state() {
690        let host = host(Ok(document(SourceFormat::Man, false, true)));
691        assert_eq!(
692            query_with(
693                &QueryRequest {
694                    schema: RequestSchema::V4,
695                    input: QueryInput::Document {
696                        name: " ".to_owned(),
697                        section: None,
698                    },
699                    view: QueryView::Full {},
700                },
701                QueryPolicy::default(),
702                &host
703            ),
704            Err(QueryError::EmptyName)
705        );
706        assert!(host.calls.lock().expect("calls lock").is_empty());
707    }
708
709    #[test]
710    fn registered_markdown_shadows_an_unqualified_manual_name() {
711        let mut host = host(Err("manual parser must not run".to_owned()));
712        host.registered_document = Some(PathBuf::from("/data/mant/tool.md"));
713        host.markdown = Ok("# Tool\n\n## Options\n\n- `--help`: Show help.\n".to_owned());
714
715        let result = query_with(&request(), QueryPolicy::default(), &host)
716            .expect("registered Markdown name");
717
718        assert_eq!(result.label, "tool");
719        assert!(result.tldr.is_none());
720        let document = result.document.expect("registered document");
721        assert_eq!(document.source.format, SourceFormat::Markdown);
722        assert_eq!(document.source.path.as_deref(), Some("/data/mant/tool.md"));
723        assert_eq!(
724            *host.calls.lock().expect("calls lock"),
725            ["name", "markdown"],
726            "a registered name must not consult man or external tldr caches"
727        );
728    }
729
730    #[test]
731    fn markdown_files_bypass_manual_and_tldr_sources() {
732        let mut host = host(Err("manual parser must not run".to_owned()));
733        host.markdown = Ok("# Tool\n\n## Options\n\n- `--help`: Show help.\n".to_owned());
734        let result = query_with(
735            &QueryRequest {
736                schema: RequestSchema::V4,
737                input: QueryInput::MarkdownFile {
738                    path: "docs/tool.md".to_owned(),
739                },
740                view: QueryView::Full {},
741            },
742            QueryPolicy::default(),
743            &host,
744        )
745        .expect("Markdown query");
746
747        assert_eq!(result.label, "tool.md");
748        assert!(result.tldr.is_none());
749        let document = result.document.expect("document");
750        assert_eq!(document.source.format, SourceFormat::Markdown);
751        assert_eq!(document.source.path.as_deref(), Some("docs/tool.md"));
752        assert_eq!(
753            *host.calls.lock().expect("calls lock"),
754            ["markdown"],
755            "Markdown must not consult man or tldr"
756        );
757    }
758
759    #[test]
760    fn in_memory_markdown_is_available_without_a_protocol_content_field() {
761        let result = query_markdown_text("# Piped\n\nBody.\n", None).expect("stdin Markdown query");
762
763        assert_eq!(result.label, "stdin");
764        assert!(result.tldr.is_none());
765        let document = result.document.expect("document");
766        assert_eq!(document.meta.title.as_deref(), Some("Piped"));
767        assert_eq!(document.source.path, None);
768    }
769
770    #[test]
771    fn leading_tldr_directives_are_independent_from_the_markdown_document() {
772        let source = "\
773<!-- mant:tldr:start -->
774# demo
775
776> Concise embedded help.
777
778- Run the demo:
779
780`demo {{path}}`
781<!-- mant:tldr:end -->
782
783# Demo
784
785Document overview.
786
787## Options
788
789- `--help`: Show help.
790";
791        let result =
792            query_markdown_text(source, Some("docs/demo.md".to_owned())).expect("Markdown query");
793
794        let tldr = result.tldr.expect("embedded tldr");
795        assert_eq!(tldr.title, "demo");
796        assert_eq!(tldr.origin, TldrOrigin::Embedded);
797        assert_eq!(tldr.source_path, "docs/demo.md");
798        assert_eq!(tldr.examples[0].command, "demo {{path}}");
799
800        let document = result.document.expect("document body");
801        assert_eq!(document.meta.title.as_deref(), Some("Demo"));
802        assert_eq!(document.sections[0].title, "Options");
803        assert!(
804            document
805                .blocks
806                .iter()
807                .any(|block| matches!(block, mant_ast::Block::Paragraph { .. }))
808        );
809        assert!(
810            document
811                .diagnostics
812                .iter()
813                .all(|diagnostic| !diagnostic.message.contains("mant:tldr"))
814        );
815    }
816
817    #[test]
818    fn malformed_leading_tldr_directives_report_the_source_path() {
819        let error = query_markdown_text(
820            "<!-- mant:tldr:start -->\n# demo\n\n- Run:\n\n`demo`\n",
821            Some("docs/broken.md".to_owned()),
822        )
823        .expect_err("unterminated directive");
824
825        assert_eq!(
826            error.to_string(),
827            "could not load Markdown document 'docs/broken.md': top-level <!-- mant:tldr:start --> marker is missing its <!-- mant:tldr:end --> marker"
828        );
829    }
830
831    #[test]
832    fn capped_read_accepts_input_up_to_the_limit() {
833        let source = "abcd";
834        assert_eq!(
835            read_capped_utf8(source.as_bytes(), source.len() as u64).expect("within limit"),
836            source
837        );
838    }
839
840    #[test]
841    fn capped_read_rejects_input_past_the_limit_without_buffering_it_whole() {
842        // An unbounded stream (modelled by io::repeat) must fail fast on the
843        // limit rather than read forever, matching the /dev/zero guard.
844        let error = read_capped_utf8(io::repeat(b'a'), 8).expect_err("over limit");
845        assert!(error.contains("exceeds the 8-byte limit"), "{error}");
846    }
847
848    #[test]
849    fn capped_read_rejects_non_utf8_input() {
850        let error =
851            read_capped_utf8(&[0xff, 0xfe][..], MAX_MARKDOWN_BYTES).expect_err("invalid UTF-8");
852        assert!(error.contains("must be UTF-8"), "{error}");
853    }
854}