1use 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
18pub const MAX_MARKDOWN_BYTES: u64 = 16 * 1024 * 1024;
25
26#[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
40pub struct QueryPolicy {
41 pub force_libmandoc: bool,
47 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
83pub fn query(request: &QueryRequest) -> Result<QueryBundle, QueryError> {
90 query_with(request, QueryPolicy::default(), &SystemQueryHost)
91}
92
93pub 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
146fn 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
200pub 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 let tldr = host.read_tldr(topic).ok().flatten();
263 let mut manual = load_manual(&manual_request, policy, host);
264
265 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 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 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 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 push_section_filter(&mut arguments, section);
392 }
393 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 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 #[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 #[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 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}