1use 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
25pub const MAX_MARKDOWN_BYTES: u64 = 16 * 1024 * 1024;
32
33#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum QueryViewResult {
73 Full(QueryBundle),
74 Outline(QueryOutline),
75 Excerpt(QueryExcerpt),
76 Search(QuerySearch),
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum QueryExecutionError {
82 Query(QueryError),
83 Projection(ProjectionError),
84 Search(SearchError),
85}
86
87#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
89pub struct QueryPolicy {
90 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
203pub fn resolve_query(request: &QueryRequest) -> Result<QueryBundle, QueryError> {
210 resolve_query_with_policy(request, QueryPolicy::default())
211}
212
213pub 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
226pub 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
239pub 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
286pub 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
382pub struct DocumentResolver {
384 registered: OnceLock<Result<RegisteredDocumentIndex, SourceConfigError>>,
385 manual_roots: Vec<PathBuf>,
386 manuals: OnceLock<ManualIndex>,
387}
388
389impl DocumentResolver {
390 #[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 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 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
477fn 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
486pub(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
530pub 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 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 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 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 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 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}