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