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_ir::{Document, DocumentAddress, MarkdownOrigin, ResolvedContent, TldrDocument};
13use mant_protocol::{
14 CatalogQuery, DocumentCatalog, InputFormat, QueryExcerpt, QueryInput, QueryOutline,
15 QueryRequest, QuerySearch, QueryView, SearchQuery,
16};
17use mant_sources::{RegisteredDocumentIndex, RegisteredDocumentOrigin, SourceConfigError};
18
19use crate::{
20 ManualIndex, ManualPage, ManualRequest, ProjectionError, SearchError,
21 build_outline_with_detail, discover_manual_roots, executable::query_name_candidates,
22 locate_manual_source_in, parse_manual_bytes, parse_manual_page, parse_manual_source,
23 parse_markdown, read_cached_tldr_page, search_query, select_excerpt, select_explanation,
24 validate_search_query,
25};
26
27pub const MAX_MARKDOWN_BYTES: u64 = 16 * 1024 * 1024;
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum QueryError {
38 EmptyName,
40 InvalidManualSection,
42 InvalidSource,
44 ConflictingSourceSelectors,
46 EmptyMarkdownPath,
48 UnsupportedInputFormat {
50 path: String,
52 },
53 EmptySelection,
55 EmptySelector,
57 EmptyEntry,
59 InvalidSearch(SearchError),
61 Markdown {
63 path: String,
65 detail: String,
67 },
68 EmptyMarkdown {
70 label: String,
72 },
73 Registry {
75 detail: String,
77 },
78 Manual(ManualLoadError),
80 ManualWithTldr {
82 error: ManualLoadError,
84 topic: String,
86 },
87 TldrNotFound {
89 topic: String,
91 },
92 Tldr {
94 topic: String,
96 detail: String,
98 },
99 NoReadableContent {
101 name: String,
103 },
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum ManualLoadError {
109 NotFound {
111 name: String,
113 detail: String,
115 },
116 Parse {
118 name: String,
120 detail: String,
122 },
123 Empty {
125 name: String,
127 path: PathBuf,
129 diagnostics: Vec<String>,
131 },
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum QueryViewResult {
137 Full(Box<ResolvedContent>),
139 Outline(QueryOutline),
141 Excerpt(QueryExcerpt),
143 Search(QuerySearch),
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum QueryExecutionError {
150 Query(QueryError),
152 Projection(ProjectionError),
154 Search(SearchError),
156}
157
158#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
160pub enum QueryPolicy {
161 #[default]
163 Combined,
164 ManualOnly,
166 TldrOnly,
168}
169
170impl fmt::Display for QueryError {
171 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
172 match self {
173 Self::EmptyName => formatter.write_str("name must not be empty"),
174 Self::InvalidManualSection => formatter.write_str("manual section must not be empty"),
175 Self::InvalidSource => formatter.write_str("document source must not be empty"),
176 Self::ConflictingSourceSelectors => formatter.write_str(
177 "document source cannot be combined with a manual section or manual-only policy",
178 ),
179 Self::EmptyMarkdownPath => formatter.write_str("Markdown path must not be empty"),
180 Self::UnsupportedInputFormat { path } => write!(
181 formatter,
182 "could not infer the input format for '{path}'; use --input-format markdown or roff"
183 ),
184 Self::EmptySelection => formatter.write_str("at least one outline node is required"),
185 Self::EmptySelector => formatter.write_str("outline node must not be empty"),
186 Self::EmptyEntry => formatter.write_str("semantic entry must not be empty"),
187 Self::InvalidSearch(error) => error.fmt(formatter),
188 Self::Markdown { path, detail } => {
189 write!(
190 formatter,
191 "could not load Markdown document '{path}': {detail}"
192 )
193 }
194 Self::EmptyMarkdown { label } => {
195 write!(
196 formatter,
197 "Markdown document '{label}' has no readable content"
198 )
199 }
200 Self::Registry { detail } => formatter.write_str(detail),
201 Self::Manual(error) => error.fmt(formatter),
202 Self::ManualWithTldr { error, topic } => {
203 error.fmt(formatter)?;
204 write!(
205 formatter,
206 "\nhint: a tldr entry is available; run `mant {topic} --tldr`"
207 )
208 }
209 Self::TldrNotFound { topic } => {
210 write!(formatter, "no tldr quick reference was found for '{topic}'")
211 }
212 Self::Tldr { topic, detail } => {
213 write!(formatter, "could not load tldr entry '{topic}': {detail}")
214 }
215 Self::NoReadableContent { name } => {
216 write!(
217 formatter,
218 "no readable document content was found for '{name}'"
219 )
220 }
221 }
222 }
223}
224
225impl Error for QueryError {
226 fn source(&self) -> Option<&(dyn Error + 'static)> {
227 match self {
228 Self::InvalidSearch(error) => Some(error),
229 Self::Manual(error) | Self::ManualWithTldr { error, .. } => Some(error),
230 Self::EmptyName
231 | Self::InvalidManualSection
232 | Self::InvalidSource
233 | Self::ConflictingSourceSelectors
234 | Self::EmptyMarkdownPath
235 | Self::UnsupportedInputFormat { .. }
236 | Self::EmptySelection
237 | Self::EmptySelector
238 | Self::EmptyEntry
239 | Self::Markdown { .. }
240 | Self::EmptyMarkdown { .. }
241 | Self::Registry { .. }
242 | Self::TldrNotFound { .. }
243 | Self::Tldr { .. }
244 | Self::NoReadableContent { .. } => None,
245 }
246 }
247}
248
249impl fmt::Display for ManualLoadError {
250 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
251 match self {
252 Self::NotFound { name, detail } => {
253 write!(formatter, "could not load manual '{name}': {detail}")
254 }
255 Self::Parse { name, detail } => write!(
256 formatter,
257 "could not load manual '{name}': manual source: {detail}"
258 ),
259 Self::Empty {
260 name,
261 path,
262 diagnostics,
263 } => {
264 write!(
265 formatter,
266 "could not load manual '{name}': libmandoc parsed {} but produced no readable sections",
267 path.display()
268 )?;
269 if !diagnostics.is_empty() {
270 write!(formatter, "; diagnostics: {}", diagnostics.join("; "))?;
271 }
272 Ok(())
273 }
274 }
275 }
276}
277
278impl Error for ManualLoadError {}
279
280impl fmt::Display for QueryExecutionError {
281 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
282 match self {
283 Self::Query(error) => error.fmt(formatter),
284 Self::Projection(error) => error.fmt(formatter),
285 Self::Search(error) => error.fmt(formatter),
286 }
287 }
288}
289
290impl Error for QueryExecutionError {
291 fn source(&self) -> Option<&(dyn Error + 'static)> {
292 match self {
293 Self::Query(error) => Some(error),
294 Self::Projection(error) => Some(error),
295 Self::Search(error) => Some(error),
296 }
297 }
298}
299
300pub fn resolve_query(request: &QueryRequest) -> Result<ResolvedContent, QueryError> {
307 resolve_query_with_policy(request, QueryPolicy::default())
308}
309
310pub fn resolve_query_with_policy(
316 request: &QueryRequest,
317 policy: QueryPolicy,
318) -> Result<ResolvedContent, QueryError> {
319 let resolver = DocumentResolver::from_system();
320 resolver.resolve(request, policy)
321}
322
323pub fn execute_query(
329 request: &QueryRequest,
330 policy: QueryPolicy,
331) -> Result<QueryViewResult, QueryExecutionError> {
332 let resolver = DocumentResolver::from_system();
333 resolver.execute(request, policy)
334}
335
336pub fn project_query_view(
342 query: ResolvedContent,
343 view: &QueryView,
344) -> Result<QueryViewResult, QueryExecutionError> {
345 match view {
346 QueryView::Full {} => Ok(QueryViewResult::Full(Box::new(query))),
347 QueryView::Outline { detail } => build_outline_with_detail(&query, *detail)
348 .map(QueryViewResult::Outline)
349 .map_err(QueryExecutionError::Projection),
350 QueryView::Excerpt { selectors } => select_excerpt(&query, selectors)
351 .map(QueryViewResult::Excerpt)
352 .map_err(QueryExecutionError::Projection),
353 QueryView::Explain { entry } => select_explanation(&query, entry)
354 .map(QueryViewResult::Excerpt)
355 .map_err(QueryExecutionError::Projection),
356 QueryView::Search {
357 pattern,
358 syntax,
359 case,
360 scope,
361 word,
362 context_lines,
363 limit,
364 offset,
365 } => search_query(
366 &query,
367 &SearchQuery {
368 pattern: pattern.clone(),
369 syntax: *syntax,
370 case: *case,
371 scope: *scope,
372 word: *word,
373 context_lines: *context_lines,
374 limit: *limit,
375 offset: *offset,
376 },
377 )
378 .map(QueryViewResult::Search)
379 .map_err(QueryExecutionError::Search),
380 }
381}
382
383pub fn validate_query_request(
389 request: &QueryRequest,
390 policy: QueryPolicy,
391) -> Result<(), QueryError> {
392 match &request.input {
393 QueryInput::Document {
394 selector,
395 source,
396 manual_section,
397 } => {
398 if selector.trim().is_empty() {
399 return Err(QueryError::EmptyName);
400 }
401 if source
402 .as_deref()
403 .is_some_and(|value| value.trim().is_empty())
404 {
405 return Err(QueryError::InvalidSource);
406 }
407 if manual_section
408 .as_deref()
409 .is_some_and(|value| value.trim().is_empty())
410 {
411 return Err(QueryError::InvalidManualSection);
412 }
413 if source.is_some() && (manual_section.is_some() || policy == QueryPolicy::ManualOnly) {
414 return Err(QueryError::ConflictingSourceSelectors);
415 }
416 }
417 QueryInput::File { path, .. } => {
418 if path.trim().is_empty() {
419 return Err(QueryError::EmptyMarkdownPath);
420 }
421 if policy != QueryPolicy::Combined {
422 return Err(QueryError::Markdown {
423 path: path.trim().to_owned(),
424 detail: "content-only policies do not apply to direct input".to_owned(),
425 });
426 }
427 }
428 }
429 match &request.view {
430 QueryView::Excerpt { selectors } => {
431 if selectors.is_empty() {
432 return Err(QueryError::EmptySelection);
433 }
434 if selectors.iter().any(|selector| selector.trim().is_empty()) {
435 return Err(QueryError::EmptySelector);
436 }
437 }
438 QueryView::Explain { entry } if entry.trim().is_empty() => {
439 return Err(QueryError::EmptyEntry);
440 }
441 QueryView::Search {
442 pattern,
443 syntax,
444 case,
445 scope,
446 word,
447 context_lines,
448 limit,
449 offset,
450 } => validate_search_query(&SearchQuery {
451 pattern: pattern.clone(),
452 syntax: *syntax,
453 case: *case,
454 scope: *scope,
455 word: *word,
456 context_lines: *context_lines,
457 limit: *limit,
458 offset: *offset,
459 })
460 .map_err(QueryError::InvalidSearch)?,
461 QueryView::Full {} | QueryView::Outline { .. } | QueryView::Explain { .. } => {}
462 }
463 Ok(())
464}
465
466trait QueryHost {
467 fn name_candidates(&self, name: &str) -> Vec<String>;
468 fn locate_registered_document(
469 &self,
470 candidates: &[String],
471 source: Option<&str>,
472 phase: RegisteredLookupPhase,
473 ) -> Result<Option<RegisteredSelection>, String>;
474 fn locate_registered_document_groups(
475 &self,
476 candidates: &[String],
477 source: Option<&str>,
478 phase: RegisteredLookupPhase,
479 ) -> Result<Vec<RegisteredSelectionGroup>, String>;
480 fn locate_registered_address(
481 &self,
482 address: &DocumentAddress,
483 ) -> Result<Option<RegisteredSelection>, String>;
484 fn locate_manual(&self, request: &ManualRequest) -> Result<ManualPage, String>;
485 fn parse_manual(&self, page: &ManualPage) -> Result<Document, String>;
486 fn parse_manual_input(&self, path: &Path) -> Result<Document, String>;
487 fn read_tldr(&self, name: &str) -> Result<Option<TldrDocument>, String>;
488 fn read_markdown(&self, path: &Path) -> Result<String, String>;
489}
490
491#[derive(Clone, Copy)]
492enum RegisteredLookupPhase {
493 BeforeBuiltin,
494 AfterBuiltin,
495}
496
497#[derive(Clone)]
498struct RegisteredSelection {
499 path: PathBuf,
500 address: DocumentAddress,
501}
502
503struct RegisteredSelectionGroup {
504 documents: Vec<RegisteredSelection>,
505}
506
507fn registered_selection(document: &mant_sources::RegisteredDocument) -> RegisteredSelection {
508 RegisteredSelection {
509 path: document.path.clone(),
510 address: DocumentAddress::Markdown {
511 path: document.logical_path.clone(),
512 origin: match &document.origin {
513 RegisteredDocumentOrigin::Documents => MarkdownOrigin::Documents,
514 RegisteredDocumentOrigin::Source(name) => {
515 MarkdownOrigin::Source { name: name.clone() }
516 }
517 },
518 },
519 }
520}
521
522struct LoadedManual {
523 document: Document,
524 address: DocumentAddress,
525}
526
527pub struct DocumentResolver {
529 registered: OnceLock<Result<RegisteredDocumentIndex, SourceConfigError>>,
530 manual_roots: Vec<PathBuf>,
531 manuals: OnceLock<ManualIndex>,
532 available: OnceLock<Vec<crate::catalog::AvailableDocument>>,
533}
534
535impl DocumentResolver {
536 #[must_use]
538 pub fn from_system() -> Self {
539 Self {
540 registered: OnceLock::new(),
541 manual_roots: discover_manual_roots(),
542 manuals: OnceLock::new(),
543 available: OnceLock::new(),
544 }
545 }
546
547 pub fn resolve(
557 &self,
558 request: &QueryRequest,
559 policy: QueryPolicy,
560 ) -> Result<ResolvedContent, QueryError> {
561 validate_query_request(request, policy)?;
562 query_with(request, policy, self)
563 }
564
565 pub fn execute(
571 &self,
572 request: &QueryRequest,
573 policy: QueryPolicy,
574 ) -> Result<QueryViewResult, QueryExecutionError> {
575 let query = self
576 .resolve(request, policy)
577 .map_err(QueryExecutionError::Query)?;
578 project_query_view(query, &request.view)
579 }
580
581 pub fn discover(&self, query: &CatalogQuery) -> Result<DocumentCatalog, String> {
589 let registered = self
590 .registered
591 .get_or_init(RegisteredDocumentIndex::load)
592 .as_ref()
593 .map_err(ToString::to_string)?;
594 let manuals = self
595 .manuals
596 .get_or_init(|| ManualIndex::from_roots(self.manual_roots.clone()));
597 let documents = self.available.get_or_init(|| {
598 crate::catalog::list_available_documents_from(
599 registered.documents().to_vec(),
600 manuals.pages(),
601 )
602 });
603 crate::catalog::query_available_documents(documents, query)
604 .map_err(|error| error.to_string())
605 }
606}
607
608impl QueryHost for DocumentResolver {
609 fn name_candidates(&self, name: &str) -> Vec<String> {
610 query_name_candidates(name)
611 }
612
613 fn locate_registered_document(
614 &self,
615 candidates: &[String],
616 source: Option<&str>,
617 phase: RegisteredLookupPhase,
618 ) -> Result<Option<RegisteredSelection>, String> {
619 let index = self
620 .registered
621 .get_or_init(RegisteredDocumentIndex::load)
622 .as_ref()
623 .map_err(ToString::to_string)?;
624 let selected = if source.is_some() {
625 index.find(candidates, source)
626 } else {
627 match phase {
628 RegisteredLookupPhase::BeforeBuiltin => index.find_before_builtin(candidates),
629 RegisteredLookupPhase::AfterBuiltin => index.find_after_builtin(candidates),
630 }
631 };
632 selected
633 .map(|registered| registered.map(registered_selection))
634 .map_err(|error| error.to_string())
635 }
636
637 fn locate_registered_document_groups(
638 &self,
639 candidates: &[String],
640 source: Option<&str>,
641 phase: RegisteredLookupPhase,
642 ) -> Result<Vec<RegisteredSelectionGroup>, String> {
643 let index = self
644 .registered
645 .get_or_init(RegisteredDocumentIndex::load)
646 .as_ref()
647 .map_err(ToString::to_string)?;
648 let groups = if let Some(source) = source {
649 index.matches_in_source(candidates, source)
650 } else {
651 Ok(match phase {
652 RegisteredLookupPhase::BeforeBuiltin => index.matches_before_builtin(candidates),
653 RegisteredLookupPhase::AfterBuiltin => index.matches_after_builtin(candidates),
654 })
655 }
656 .map_err(|error| error.to_string())?;
657 Ok(groups
658 .into_iter()
659 .map(|group| RegisteredSelectionGroup {
660 documents: group.documents.iter().map(registered_selection).collect(),
661 })
662 .collect())
663 }
664
665 fn locate_registered_address(
666 &self,
667 address: &DocumentAddress,
668 ) -> Result<Option<RegisteredSelection>, String> {
669 let DocumentAddress::Markdown { path, origin } = address else {
670 return Ok(None);
671 };
672 let origin = match origin {
673 MarkdownOrigin::Documents => RegisteredDocumentOrigin::Documents,
674 MarkdownOrigin::Source { name } => RegisteredDocumentOrigin::Source(name.clone()),
675 };
676 let index = self
677 .registered
678 .get_or_init(RegisteredDocumentIndex::load)
679 .as_ref()
680 .map_err(ToString::to_string)?;
681 index
682 .find_address(path, &origin)
683 .map(|document| {
684 document.map(|document| RegisteredSelection {
685 path: document.path.clone(),
686 address: address.clone(),
687 })
688 })
689 .map_err(|error| error.to_string())
690 }
691
692 fn locate_manual(&self, request: &ManualRequest) -> Result<ManualPage, String> {
693 let manuals = self
694 .manuals
695 .get_or_init(|| ManualIndex::from_roots(self.manual_roots.clone()));
696 locate_manual_source_in(request, manuals).map_err(|error| error.to_string())
697 }
698
699 fn parse_manual(&self, page: &ManualPage) -> Result<Document, String> {
700 parse_manual_page(page).map_err(|error| error.to_string())
701 }
702
703 fn parse_manual_input(&self, path: &Path) -> Result<Document, String> {
704 parse_manual_source(path).map_err(|error| error.to_string())
705 }
706
707 fn read_tldr(&self, name: &str) -> Result<Option<TldrDocument>, String> {
708 read_cached_tldr_page(name).map_err(|error| error.to_string())
709 }
710
711 fn read_markdown(&self, path: &Path) -> Result<String, String> {
712 let file = fs::File::open(path).map_err(|error| error.to_string())?;
713 read_capped_utf8(file, MAX_MARKDOWN_BYTES)
714 }
715}
716
717fn read_capped_utf8(reader: impl Read, limit: u64) -> Result<String, String> {
723 read_capped_utf8_io(reader, limit).map_err(|error| error.to_string())
724}
725
726pub(crate) fn read_capped_utf8_io(reader: impl Read, limit: u64) -> io::Result<String> {
728 crate::bounded::read_utf8(reader, limit, "Markdown document")
729}
730
731fn query_with(
732 request: &QueryRequest,
733 policy: QueryPolicy,
734 host: &dyn QueryHost,
735) -> Result<ResolvedContent, QueryError> {
736 match &request.input {
737 QueryInput::Document {
738 selector,
739 source,
740 manual_section,
741 } => query_named_document(
742 selector,
743 source.as_deref(),
744 manual_section.as_deref(),
745 policy,
746 host,
747 ),
748 QueryInput::File { path, format } => query_input_file(path, *format, policy, host),
749 }
750}
751
752fn query_input_file(
753 requested_path: &str,
754 format: InputFormat,
755 policy: QueryPolicy,
756 host: &dyn QueryHost,
757) -> Result<ResolvedContent, QueryError> {
758 let path = requested_path.trim();
759 if path.is_empty() {
760 return Err(QueryError::EmptyMarkdownPath);
761 }
762 let format = match format {
763 InputFormat::Auto => {
764 detect_input_format(path).ok_or_else(|| QueryError::UnsupportedInputFormat {
765 path: path.to_owned(),
766 })?
767 }
768 format => format,
769 };
770 match format {
771 InputFormat::Markdown => query_markdown_file(path, policy, host),
772 InputFormat::Roff => {
773 if policy != QueryPolicy::Combined {
774 return Err(QueryError::ConflictingSourceSelectors);
775 }
776 let document = host.parse_manual_input(Path::new(path)).map_err(|detail| {
777 QueryError::Manual(ManualLoadError::Parse {
778 name: path.to_owned(),
779 detail,
780 })
781 })?;
782 if document.sections.is_empty() && document.blocks.is_empty() {
783 return Err(QueryError::NoReadableContent {
784 name: path.to_owned(),
785 });
786 }
787 let label = document
788 .meta
789 .names
790 .first()
791 .cloned()
792 .or_else(|| document.meta.title.clone())
793 .unwrap_or_else(|| input_file_label(path));
794 Ok(ResolvedContent {
795 label,
796 address: None,
797 document: Some(document),
798 tldr: None,
799 })
800 }
801 InputFormat::Auto => unreachable!("auto input was resolved above"),
802 }
803}
804
805fn detect_input_format(path: &str) -> Option<InputFormat> {
806 let mut name = Path::new(path).file_name()?.to_str()?.to_ascii_lowercase();
807 let mut compressed = false;
808 if Path::new(&name)
809 .extension()
810 .and_then(OsStr::to_str)
811 .is_some_and(|extension| matches!(extension, "gz" | "zst"))
812 {
813 name = Path::new(&name).file_stem()?.to_str()?.to_owned();
814 compressed = true;
815 }
816 let extension = Path::new(&name).extension()?.to_str()?;
817 if matches!(extension, "md" | "markdown") {
818 return (!compressed).then_some(InputFormat::Markdown);
819 }
820 if matches!(extension, "roff" | "man" | "mdoc") {
821 return Some(InputFormat::Roff);
822 }
823 let section = extension;
824 let valid_section = matches!(section, "l" | "n")
825 || section
826 .chars()
827 .next()
828 .is_some_and(|first| first.is_ascii_digit())
829 && section
830 .chars()
831 .all(|character| character.is_ascii_alphanumeric());
832 valid_section.then_some(InputFormat::Roff)
833}
834
835fn input_file_label(path: &str) -> String {
836 Path::new(path)
837 .file_name()
838 .and_then(OsStr::to_str)
839 .unwrap_or(path)
840 .to_owned()
841}
842
843fn query_markdown_file(
844 requested_path: &str,
845 policy: QueryPolicy,
846 host: &dyn QueryHost,
847) -> Result<ResolvedContent, QueryError> {
848 let path = requested_path.trim();
849 if path.is_empty() {
850 return Err(QueryError::EmptyMarkdownPath);
851 }
852 if policy != QueryPolicy::Combined {
853 return Err(QueryError::Markdown {
854 path: path.to_owned(),
855 detail: "content-only policies do not apply to direct input".to_owned(),
856 });
857 }
858 let source = host
859 .read_markdown(Path::new(path))
860 .map_err(|detail| QueryError::Markdown {
861 path: path.to_owned(),
862 detail,
863 })?;
864 query_markdown_text(&source, Some(path.to_owned()))
865}
866
867pub fn query_markdown_text(
877 source: &str,
878 source_path: Option<String>,
879) -> Result<ResolvedContent, QueryError> {
880 let label = source_path.as_deref().map_or_else(
881 || "stdin".to_owned(),
882 |path| {
883 Path::new(path)
884 .file_name()
885 .and_then(OsStr::to_str)
886 .unwrap_or(path)
887 .to_owned()
888 },
889 );
890 let error_path = source_path.clone().unwrap_or_else(|| "stdin".to_owned());
891 let parsed = parse_markdown(source, source_path).map_err(|error| QueryError::Markdown {
892 path: error_path,
893 detail: error.to_string(),
894 })?;
895 let document_is_empty =
896 parsed.document.blocks.is_empty() && parsed.document.sections.is_empty();
897 if document_is_empty && parsed.tldr.is_none() {
898 return Err(QueryError::EmptyMarkdown {
899 label: label.clone(),
900 });
901 }
902 Ok(ResolvedContent {
903 address: None,
904 label,
905 document: (!document_is_empty).then_some(parsed.document),
906 tldr: parsed.tldr,
907 })
908}
909
910pub fn query_roff_bytes(source: &[u8]) -> Result<ResolvedContent, QueryError> {
916 if u64::try_from(source.len()).unwrap_or(u64::MAX) > crate::MAX_MANUAL_BYTES {
917 return Err(QueryError::Manual(ManualLoadError::Parse {
918 name: "stdin".to_owned(),
919 detail: format!(
920 "roff input exceeds the {}-byte limit",
921 crate::MAX_MANUAL_BYTES
922 ),
923 }));
924 }
925 let document = parse_manual_bytes(Path::new("stdin"), source).map_err(|error| {
926 QueryError::Manual(ManualLoadError::Parse {
927 name: "stdin".to_owned(),
928 detail: error.to_string(),
929 })
930 })?;
931 if document.sections.is_empty() && document.blocks.is_empty() {
932 return Err(QueryError::NoReadableContent {
933 name: "stdin".to_owned(),
934 });
935 }
936 let label = document
937 .meta
938 .names
939 .first()
940 .cloned()
941 .or_else(|| document.meta.title.clone())
942 .unwrap_or_else(|| "stdin".to_owned());
943 Ok(ResolvedContent {
944 address: None,
945 label,
946 document: Some(document),
947 tldr: None,
948 })
949}
950
951fn query_named_document(
952 name: &str,
953 requested_source: Option<&str>,
954 requested_manual_section: Option<&str>,
955 policy: QueryPolicy,
956 host: &dyn QueryHost,
957) -> Result<ResolvedContent, QueryError> {
958 let name = name.trim();
959 if name.is_empty() {
960 return Err(QueryError::EmptyName);
961 }
962 if let Some(address) = parse_catalog_address(name) {
963 if requested_source.is_some()
964 || requested_manual_section.is_some()
965 || policy == QueryPolicy::ManualOnly
966 {
967 return Err(QueryError::ConflictingSourceSelectors);
968 }
969 return query_catalog_address(name, &address, policy, host);
970 }
971 let section = requested_manual_section.map(str::trim);
972 if section.is_some_and(str::is_empty) {
973 return Err(QueryError::InvalidManualSection);
974 }
975 let section = section.map(ToOwned::to_owned);
976 let source = requested_source.map(str::trim);
977 if source.is_some_and(str::is_empty) {
978 return Err(QueryError::InvalidSource);
979 }
980 if source.is_some() && (section.is_some() || policy == QueryPolicy::ManualOnly) {
981 return Err(QueryError::ConflictingSourceSelectors);
982 }
983 if policy == QueryPolicy::TldrOnly && section.is_some() {
984 return Err(QueryError::ConflictingSourceSelectors);
985 }
986 let require_manual = policy == QueryPolicy::ManualOnly || section.is_some();
987 let candidates = host.name_candidates(name);
988
989 if policy == QueryPolicy::TldrOnly {
990 return query_tldr_only(name, &candidates, source, host);
991 }
992
993 if section.is_none() && policy == QueryPolicy::Combined {
998 let registered = host
999 .locate_registered_document(&candidates, source, RegisteredLookupPhase::BeforeBuiltin)
1000 .map_err(|detail| QueryError::Registry { detail })?;
1001 if let Some(registered) = registered {
1002 return query_registered_document(name, ®istered, host);
1003 }
1004 if source.is_some() {
1005 return Err(QueryError::NoReadableContent {
1006 name: name.to_owned(),
1007 });
1008 }
1009 }
1010
1011 let tldr = if require_manual {
1016 None
1017 } else {
1018 host.read_tldr(name).ok().flatten()
1019 };
1020 let mut manual = load_manual(name, &candidates, section.as_deref(), host);
1021
1022 if let (Ok(manual), Some(section)) = (&mut manual, section.as_deref())
1025 && manual.document.meta.manual_section.is_none()
1026 {
1027 manual.document.meta.manual_section = Some(section.to_owned());
1028 }
1029
1030 if require_manual {
1033 return match manual {
1034 Ok(manual) => Ok(ResolvedContent {
1035 address: Some(manual.address),
1036 label: name.to_owned(),
1037 document: Some(manual.document),
1038 tldr,
1039 }),
1040 Err(error) => Err(QueryError::Manual(error)),
1041 };
1042 }
1043
1044 finish_unqualified_manual(name, &candidates, manual, tldr, host)
1045}
1046
1047fn query_catalog_address(
1048 selector: &str,
1049 address: &DocumentAddress,
1050 policy: QueryPolicy,
1051 host: &dyn QueryHost,
1052) -> Result<ResolvedContent, QueryError> {
1053 match address {
1054 DocumentAddress::Markdown { .. } if policy == QueryPolicy::TldrOnly => {
1055 let registered = host
1056 .locate_registered_address(address)
1057 .map_err(|detail| QueryError::Registry { detail })?
1058 .ok_or_else(|| QueryError::TldrNotFound {
1059 topic: selector.to_owned(),
1060 })?;
1061 query_registered_tldr(selector, ®istered, host)?.ok_or_else(|| {
1062 QueryError::TldrNotFound {
1063 topic: selector.to_owned(),
1064 }
1065 })
1066 }
1067 DocumentAddress::Markdown { .. } => {
1068 let registered = host
1069 .locate_registered_address(address)
1070 .map_err(|detail| QueryError::Registry { detail })?
1071 .ok_or_else(|| QueryError::NoReadableContent {
1072 name: selector.to_owned(),
1073 })?;
1074 query_registered_document(selector, ®istered, host)
1075 }
1076 DocumentAddress::Manual { .. } if policy == QueryPolicy::TldrOnly => {
1077 Err(QueryError::ConflictingSourceSelectors)
1078 }
1079 DocumentAddress::Manual {
1080 name,
1081 manual_section,
1082 } => query_named_document(
1083 name,
1084 None,
1085 Some(manual_section),
1086 QueryPolicy::ManualOnly,
1087 host,
1088 ),
1089 }
1090}
1091
1092fn query_tldr_only(
1093 name: &str,
1094 candidates: &[String],
1095 source: Option<&str>,
1096 host: &dyn QueryHost,
1097) -> Result<ResolvedContent, QueryError> {
1098 let before = host
1099 .locate_registered_document_groups(candidates, source, RegisteredLookupPhase::BeforeBuiltin)
1100 .map_err(|detail| QueryError::Registry { detail })?;
1101 if let Some(tldr) = first_registered_tldr(name, before, host)? {
1102 return Ok(tldr);
1103 }
1104 if source.is_some() {
1105 return Err(QueryError::TldrNotFound {
1106 topic: name.to_owned(),
1107 });
1108 }
1109
1110 if let Some(tldr) = host.read_tldr(name).map_err(|detail| QueryError::Tldr {
1111 topic: name.to_owned(),
1112 detail,
1113 })? {
1114 return Ok(ResolvedContent {
1115 address: None,
1116 label: name.to_owned(),
1117 document: None,
1118 tldr: Some(tldr),
1119 });
1120 }
1121
1122 let after = host
1123 .locate_registered_document_groups(candidates, None, RegisteredLookupPhase::AfterBuiltin)
1124 .map_err(|detail| QueryError::Registry { detail })?;
1125 first_registered_tldr(name, after, host)?.ok_or_else(|| QueryError::TldrNotFound {
1126 topic: name.to_owned(),
1127 })
1128}
1129
1130fn first_registered_tldr(
1131 name: &str,
1132 groups: Vec<RegisteredSelectionGroup>,
1133 host: &dyn QueryHost,
1134) -> Result<Option<ResolvedContent>, QueryError> {
1135 for group in groups {
1136 let mut matches = Vec::new();
1137 for registered in group.documents {
1138 if let Some(tldr) = query_registered_tldr(name, ®istered, host)? {
1139 matches.push(tldr);
1140 }
1141 }
1142 match matches.len() {
1143 0 => {}
1144 1 => return Ok(matches.pop()),
1145 _ => {
1146 let choices = matches
1147 .iter()
1148 .filter_map(|candidate| candidate.address.as_ref())
1149 .map(DocumentAddress::catalog_path)
1150 .collect::<Vec<_>>()
1151 .join("', '");
1152 return Err(QueryError::Registry {
1153 detail: format!(
1154 "tldr selector '{name}' is ambiguous at one document priority: '{choices}'"
1155 ),
1156 });
1157 }
1158 }
1159 }
1160 Ok(None)
1161}
1162
1163fn query_registered_tldr(
1164 name: &str,
1165 registered: &RegisteredSelection,
1166 host: &dyn QueryHost,
1167) -> Result<Option<ResolvedContent>, QueryError> {
1168 let resolved = query_registered_document(name, registered, host)?;
1169 let Some(tldr) = resolved.tldr else {
1170 return Ok(None);
1171 };
1172 Ok(Some(ResolvedContent {
1173 address: resolved.address,
1174 label: resolved.label,
1175 document: None,
1176 tldr: Some(tldr),
1177 }))
1178}
1179
1180fn finish_unqualified_manual(
1181 name: &str,
1182 candidates: &[String],
1183 manual: Result<LoadedManual, ManualLoadError>,
1184 tldr: Option<TldrDocument>,
1185 host: &dyn QueryHost,
1186) -> Result<ResolvedContent, QueryError> {
1187 match manual {
1188 Ok(manual) => Ok(ResolvedContent {
1189 address: Some(manual.address),
1190 label: name.to_owned(),
1191 document: Some(manual.document),
1192 tldr,
1193 }),
1194 Err(error) => {
1195 let registered = host
1196 .locate_registered_document(candidates, None, RegisteredLookupPhase::AfterBuiltin)
1197 .map_err(|detail| QueryError::Registry { detail })?;
1198 if let Some(registered) = registered {
1199 query_registered_document(name, ®istered, host)
1200 } else if tldr.is_some() {
1201 Err(QueryError::ManualWithTldr {
1202 error,
1203 topic: name.to_owned(),
1204 })
1205 } else {
1206 Err(QueryError::Manual(error))
1207 }
1208 }
1209 }
1210}
1211
1212fn parse_catalog_address(selector: &str) -> Option<DocumentAddress> {
1213 if let Some(path) = selector.strip_prefix("documents/")
1214 && !path.is_empty()
1215 {
1216 return Some(DocumentAddress::Markdown {
1217 path: path.to_owned(),
1218 origin: MarkdownOrigin::Documents,
1219 });
1220 }
1221 if let Some(rest) = selector.strip_prefix("sources/") {
1222 let (source, path) = rest.split_once('/')?;
1223 if !source.is_empty() && !path.is_empty() {
1224 return Some(DocumentAddress::Markdown {
1225 path: path.to_owned(),
1226 origin: MarkdownOrigin::Source {
1227 name: source.to_owned(),
1228 },
1229 });
1230 }
1231 }
1232 if let Some(rest) = selector.strip_prefix("manual/") {
1233 let (manual_section, name) = rest.split_once('/')?;
1234 if !manual_section.is_empty() && !name.is_empty() && !name.contains('/') {
1235 return Some(DocumentAddress::Manual {
1236 name: name.to_owned(),
1237 manual_section: manual_section.to_owned(),
1238 });
1239 }
1240 }
1241 None
1242}
1243
1244fn query_registered_document(
1245 name: &str,
1246 registered: &RegisteredSelection,
1247 host: &dyn QueryHost,
1248) -> Result<ResolvedContent, QueryError> {
1249 let path = ®istered.path;
1250 let source_path = path.to_string_lossy().into_owned();
1251 let source = host
1252 .read_markdown(path)
1253 .map_err(|detail| QueryError::Markdown {
1254 path: source_path.clone(),
1255 detail,
1256 })?;
1257 let mut query = query_markdown_text(&source, Some(source_path))?;
1258 name.clone_into(&mut query.label);
1259 query.address = Some(registered.address.clone());
1260 Ok(query)
1261}
1262
1263fn load_manual(
1264 requested_name: &str,
1265 candidates: &[String],
1266 section: Option<&str>,
1267 host: &dyn QueryHost,
1268) -> Result<LoadedManual, ManualLoadError> {
1269 let mut first_locate_error = None;
1270 let mut located = None;
1271 for candidate in candidates {
1272 let request = ManualRequest::new(candidate, section.map(ToOwned::to_owned));
1273 match host.locate_manual(&request) {
1274 Ok(page) => {
1275 located = Some(page);
1276 break;
1277 }
1278 Err(error) => {
1279 first_locate_error.get_or_insert(error);
1280 }
1281 }
1282 }
1283 let Some(page) = located else {
1284 let error =
1285 first_locate_error.unwrap_or_else(|| "no name candidates were available".to_owned());
1286 return Err(ManualLoadError::NotFound {
1287 name: requested_name.to_owned(),
1288 detail: error,
1289 });
1290 };
1291
1292 let source_path = page.path.clone();
1293 let address = DocumentAddress::Manual {
1294 name: page.name.clone(),
1295 manual_section: page.section.clone(),
1296 };
1297 let document = host
1298 .parse_manual(&page)
1299 .map_err(|detail| ManualLoadError::Parse {
1300 name: requested_name.to_owned(),
1301 detail,
1302 })?;
1303 if document.sections.is_empty() {
1304 let diagnostics = document
1305 .diagnostics
1306 .iter()
1307 .map(|diagnostic| {
1308 let location = diagnostic.source.map_or_else(String::new, |source| {
1309 format!(" at {}:{}", source.line, source.column)
1310 });
1311 format!("{:?}{location}: {}", diagnostic.level, diagnostic.message)
1312 })
1313 .collect::<Vec<_>>();
1314 return Err(ManualLoadError::Empty {
1315 name: requested_name.to_owned(),
1316 path: source_path,
1317 diagnostics,
1318 });
1319 }
1320 Ok(LoadedManual { document, address })
1321}
1322
1323#[cfg(test)]
1324mod tests {
1325 use std::{
1326 io,
1327 path::{Path, PathBuf},
1328 sync::Mutex,
1329 };
1330
1331 use mant_ir::{
1332 Diagnostic, DiagnosticLevel, Document, DocumentMeta, DocumentSource, Section, SourceFormat,
1333 TldrDocument, TldrOrigin,
1334 };
1335 use mant_protocol::{
1336 DocumentAddress, InputFormat, MarkdownOrigin, QueryInput, QueryRequest, QueryView,
1337 RequestSchema,
1338 };
1339 use mant_sources::BUILTIN_CONTENT_PRIORITY;
1340
1341 use crate::{ManualPage, ManualRequest};
1342
1343 use super::{
1344 MAX_MARKDOWN_BYTES, QueryError, QueryHost, QueryPolicy, RegisteredLookupPhase,
1345 RegisteredSelection, RegisteredSelectionGroup, query_markdown_text, query_with,
1346 read_capped_utf8, read_capped_utf8_io,
1347 };
1348
1349 #[derive(Clone)]
1350 struct StubHost {
1351 name_candidates: Option<Vec<String>>,
1352 registered_document: Option<PathBuf>,
1353 registered_name: Option<String>,
1354 registered_source_priority: Option<i32>,
1355 locate: Result<ManualPage, String>,
1356 manual_name: Option<String>,
1357 direct: Result<Document, String>,
1358 tldr: Result<Option<TldrDocument>, String>,
1359 markdown: Result<String, String>,
1360 calls: std::sync::Arc<Mutex<Vec<&'static str>>>,
1361 }
1362
1363 impl QueryHost for StubHost {
1364 fn name_candidates(&self, name: &str) -> Vec<String> {
1365 self.name_candidates
1366 .clone()
1367 .unwrap_or_else(|| vec![name.to_owned()])
1368 }
1369
1370 fn locate_registered_document(
1371 &self,
1372 candidates: &[String],
1373 source: Option<&str>,
1374 phase: RegisteredLookupPhase,
1375 ) -> Result<Option<RegisteredSelection>, String> {
1376 self.calls
1377 .lock()
1378 .expect("calls lock")
1379 .push(if source.is_some() {
1380 "source"
1381 } else {
1382 match phase {
1383 RegisteredLookupPhase::BeforeBuiltin => "name",
1384 RegisteredLookupPhase::AfterBuiltin => "fallback",
1385 }
1386 });
1387 if source.is_none()
1388 && match phase {
1389 RegisteredLookupPhase::BeforeBuiltin => self
1390 .registered_source_priority
1391 .is_some_and(|priority| priority <= BUILTIN_CONTENT_PRIORITY),
1392 RegisteredLookupPhase::AfterBuiltin => self
1393 .registered_source_priority
1394 .is_none_or(|priority| priority > BUILTIN_CONTENT_PRIORITY),
1395 }
1396 {
1397 return Ok(None);
1398 }
1399 if self
1400 .registered_name
1401 .as_deref()
1402 .is_some_and(|registered_name| {
1403 !candidates
1404 .iter()
1405 .any(|candidate| candidate == registered_name)
1406 })
1407 {
1408 return Ok(None);
1409 }
1410 Ok(self
1411 .registered_document
1412 .clone()
1413 .map(|path| RegisteredSelection {
1414 path,
1415 address: DocumentAddress::Markdown {
1416 path: self
1417 .registered_name
1418 .clone()
1419 .unwrap_or_else(|| candidates[0].clone()),
1420 origin: source.map_or_else(
1421 || {
1422 self.registered_source_priority.map_or(
1423 MarkdownOrigin::Documents,
1424 |_| MarkdownOrigin::Source {
1425 name: "team".to_owned(),
1426 },
1427 )
1428 },
1429 |name| MarkdownOrigin::Source {
1430 name: name.to_owned(),
1431 },
1432 ),
1433 },
1434 }))
1435 }
1436
1437 fn locate_registered_document_groups(
1438 &self,
1439 candidates: &[String],
1440 source: Option<&str>,
1441 phase: RegisteredLookupPhase,
1442 ) -> Result<Vec<RegisteredSelectionGroup>, String> {
1443 self.locate_registered_document(candidates, source, phase)
1444 .map(|selection| {
1445 selection
1446 .map(|value| {
1447 vec![RegisteredSelectionGroup {
1448 documents: vec![value],
1449 }]
1450 })
1451 .unwrap_or_default()
1452 })
1453 }
1454
1455 fn locate_registered_address(
1456 &self,
1457 address: &DocumentAddress,
1458 ) -> Result<Option<RegisteredSelection>, String> {
1459 self.calls.lock().expect("calls lock").push("address");
1460 Ok(self
1461 .registered_document
1462 .clone()
1463 .map(|path| RegisteredSelection {
1464 path,
1465 address: address.clone(),
1466 }))
1467 }
1468
1469 fn locate_manual(&self, request: &ManualRequest) -> Result<ManualPage, String> {
1470 self.calls.lock().expect("calls lock").push("locate");
1471 if self
1472 .manual_name
1473 .as_deref()
1474 .is_some_and(|manual_name| manual_name != request.name)
1475 {
1476 return Err("source not found".to_owned());
1477 }
1478 self.locate.clone()
1479 }
1480
1481 fn parse_manual(&self, _page: &ManualPage) -> Result<Document, String> {
1482 self.calls.lock().expect("calls lock").push("parse");
1483 self.direct.clone()
1484 }
1485
1486 fn parse_manual_input(&self, _path: &Path) -> Result<Document, String> {
1487 self.calls.lock().expect("calls lock").push("manual-input");
1488 self.direct.clone()
1489 }
1490
1491 fn read_tldr(&self, _name: &str) -> Result<Option<TldrDocument>, String> {
1492 self.calls.lock().expect("calls lock").push("tldr");
1493 self.tldr.clone()
1494 }
1495
1496 fn read_markdown(&self, _path: &Path) -> Result<String, String> {
1497 self.calls.lock().expect("calls lock").push("markdown");
1498 self.markdown.clone()
1499 }
1500 }
1501
1502 fn document(format: SourceFormat, unsupported: bool, readable: bool) -> Document {
1503 Document {
1504 parser: None,
1505 source: DocumentSource { format, path: None },
1506 meta: DocumentMeta::default(),
1507 diagnostics: unsupported
1508 .then_some(Diagnostic {
1509 level: DiagnosticLevel::Unsupported,
1510 code: None,
1511 message: "unsupported request".to_owned(),
1512 source: None,
1513 })
1514 .into_iter()
1515 .collect(),
1516 blocks: Vec::new(),
1517 sections: readable
1518 .then_some(Section {
1519 id: "name-1".to_owned().into(),
1520 title: "NAME".to_owned(),
1521 spacing_before_lines: 0,
1522 blocks: Vec::new(),
1523 children: Vec::new(),
1524 source: None,
1525 })
1526 .into_iter()
1527 .collect(),
1528 }
1529 }
1530
1531 fn tldr() -> TldrDocument {
1532 TldrDocument {
1533 title: "tool".to_owned(),
1534 description: vec!["quick reference".to_owned()],
1535 more_information: None,
1536 examples: Vec::new(),
1537 platform: "common".to_owned(),
1538 language: "en".to_owned(),
1539 source_path: "/cache/pages/common/tool.md".to_owned(),
1540 origin: TldrOrigin::TldrPages,
1541 }
1542 }
1543
1544 fn embedded_tldr_markdown() -> String {
1545 "\
1546<!-- mant:tldr:start -->
1547# tool
1548
1549> Source-owned quick reference.
1550
1551- Run the tool:
1552
1553`tool`
1554<!-- mant:tldr:end -->
1555
1556# Tool
1557
1558Full documentation.
1559"
1560 .to_owned()
1561 }
1562
1563 fn host(direct: Result<Document, String>) -> StubHost {
1564 StubHost {
1565 name_candidates: None,
1566 registered_document: None,
1567 registered_name: None,
1568 registered_source_priority: None,
1569 locate: Ok(ManualPage {
1570 name: "tool".to_owned(),
1571 section: "1".to_owned(),
1572 path: PathBuf::from("/man/tool.1"),
1573 manual_root: PathBuf::from("/man"),
1574 }),
1575 manual_name: None,
1576 direct,
1577 tldr: Ok(None),
1578 markdown: Err("Markdown unavailable".to_owned()),
1579 calls: std::sync::Arc::default(),
1580 }
1581 }
1582
1583 fn request() -> QueryRequest {
1584 QueryRequest {
1585 schema: RequestSchema::V7,
1586 input: QueryInput::Document {
1587 selector: " tool ".to_owned(),
1588 source: None,
1589 manual_section: None,
1590 },
1591 view: QueryView::Full {},
1592 }
1593 }
1594
1595 #[test]
1596 fn ordinary_manual_uses_the_native_parser() {
1597 let host = host(Ok(document(SourceFormat::Man, false, true)));
1598 let result = query_with(&request(), QueryPolicy::default(), &host).expect("query");
1599
1600 assert_eq!(result.label, "tool");
1601 assert_eq!(
1602 result.document.expect("manual").source.format,
1603 SourceFormat::Man
1604 );
1605 assert_eq!(
1606 *host.calls.lock().expect("calls lock"),
1607 ["name", "tldr", "locate", "parse"]
1608 );
1609 }
1610
1611 #[test]
1612 fn requested_manual_section_backfills_metadata_the_parser_left_empty() {
1613 let mut host = host(Ok(document(SourceFormat::Man, false, true)));
1614 host.locate.as_mut().expect("manual page").section = "3".to_owned();
1615 host.tldr = Ok(Some(tldr()));
1616 let request = QueryRequest {
1617 schema: RequestSchema::V7,
1618 input: QueryInput::Document {
1619 selector: "tool".to_owned(),
1620 source: None,
1621 manual_section: Some("3".to_owned()),
1622 },
1623 view: QueryView::Full {},
1624 };
1625
1626 let result = query_with(&request, QueryPolicy::default(), &host).expect("query");
1627 assert_eq!(
1628 result.address,
1629 Some(DocumentAddress::Manual {
1630 name: "tool".to_owned(),
1631 manual_section: "3".to_owned(),
1632 })
1633 );
1634 assert_eq!(
1635 result
1636 .document
1637 .as_ref()
1638 .expect("manual")
1639 .meta
1640 .manual_section
1641 .as_deref(),
1642 Some("3"),
1643 "requested section must label output when the parser omits it"
1644 );
1645 assert!(result.tldr.is_none(), "an explicit section is manual-only");
1646 assert_eq!(
1647 *host.calls.lock().expect("calls lock"),
1648 ["locate", "parse"],
1649 "an explicit manual section bypasses Markdown and tldr"
1650 );
1651 }
1652
1653 #[test]
1654 fn explicit_source_reads_only_registered_markdown() {
1655 let mut host = host(Err("manual must not be read".to_owned()));
1656 host.registered_document = Some(PathBuf::from("/documents/tool.md"));
1657 host.markdown = Ok("# Tool\n\nSource body.\n".to_owned());
1658 let request = QueryRequest {
1659 schema: RequestSchema::V7,
1660 input: QueryInput::Document {
1661 selector: "tool".to_owned(),
1662 source: Some("team".to_owned()),
1663 manual_section: None,
1664 },
1665 view: QueryView::Full {},
1666 };
1667 let result = query_with(&request, QueryPolicy::default(), &host).expect("source query");
1668 assert_eq!(
1669 result.address,
1670 Some(DocumentAddress::Markdown {
1671 path: "tool".to_owned(),
1672 origin: MarkdownOrigin::Source {
1673 name: "team".to_owned(),
1674 },
1675 })
1676 );
1677 assert_eq!(
1678 result.document.expect("Markdown").meta.title.as_deref(),
1679 Some("Tool")
1680 );
1681 assert_eq!(
1682 *host.calls.lock().expect("calls lock"),
1683 ["source", "markdown"]
1684 );
1685 }
1686
1687 #[test]
1688 fn canonical_catalog_paths_resolve_exact_addresses() {
1689 let mut markdown = host(Err("manual must not be read".to_owned()));
1690 markdown.registered_document = Some(PathBuf::from("/documents/en/tool.md"));
1691 markdown.markdown = Ok("# Tool\n\nBody.\n".to_owned());
1692 let request = QueryRequest {
1693 schema: RequestSchema::V7,
1694 input: QueryInput::Document {
1695 selector: "documents/en/tool".to_owned(),
1696 source: None,
1697 manual_section: None,
1698 },
1699 view: QueryView::Full {},
1700 };
1701 let result = query_with(&request, QueryPolicy::default(), &markdown).expect("canonical");
1702 assert_eq!(
1703 result.address,
1704 Some(DocumentAddress::Markdown {
1705 path: "en/tool".to_owned(),
1706 origin: MarkdownOrigin::Documents,
1707 })
1708 );
1709 assert_eq!(
1710 *markdown.calls.lock().expect("calls"),
1711 ["address", "markdown"]
1712 );
1713
1714 let manual = host(Ok(document(SourceFormat::Man, false, true)));
1715 let request = QueryRequest {
1716 schema: RequestSchema::V7,
1717 input: QueryInput::Document {
1718 selector: "manual/1/tool".to_owned(),
1719 source: None,
1720 manual_section: None,
1721 },
1722 view: QueryView::Full {},
1723 };
1724 let result = query_with(&request, QueryPolicy::default(), &manual).expect("manual path");
1725 assert_eq!(
1726 result.address,
1727 Some(DocumentAddress::Manual {
1728 name: "tool".to_owned(),
1729 manual_section: "1".to_owned(),
1730 })
1731 );
1732 assert_eq!(*manual.calls.lock().expect("calls"), ["locate", "parse"]);
1733 }
1734
1735 #[test]
1736 fn complete_direct_document_survives_an_unsupported_finding() {
1737 let host = host(Ok(document(SourceFormat::Man, true, true)));
1738 let result = query_with(&request(), QueryPolicy::default(), &host).expect("query");
1739
1740 assert_eq!(
1741 result.document.expect("manual").source.format,
1742 SourceFormat::Man
1743 );
1744 assert_eq!(
1745 *host.calls.lock().expect("calls lock"),
1746 ["name", "tldr", "locate", "parse"]
1747 );
1748 }
1749
1750 #[test]
1751 fn manual_only_bypasses_registered_markdown() {
1752 let mut host = host(Ok(document(SourceFormat::Man, true, true)));
1753 host.registered_document = Some(PathBuf::from("/data/mant/tool.md"));
1754 host.markdown = Ok("# Registered".to_owned());
1755 host.tldr = Ok(Some(tldr()));
1756 let result =
1757 query_with(&request(), QueryPolicy::ManualOnly, &host).expect("manual-only query");
1758
1759 assert_eq!(
1760 result.document.as_ref().expect("manual").source.format,
1761 SourceFormat::Man
1762 );
1763 assert!(result.tldr.is_none(), "manual-only must not attach tldr");
1764 assert_eq!(
1765 *host.calls.lock().expect("calls lock"),
1766 ["locate", "parse"],
1767 "manual-only lookup must not inspect Markdown or tldr namespaces"
1768 );
1769 }
1770
1771 #[test]
1772 fn manual_only_failure_is_not_hidden_by_tldr() {
1773 let mut host = host(Ok(document(SourceFormat::Man, true, false)));
1774 host.tldr = Ok(Some(tldr()));
1775
1776 let error = query_with(&request(), QueryPolicy::ManualOnly, &host)
1777 .expect_err("an optional tldr page must not hide native parser failure");
1778
1779 let QueryError::Manual(detail) = error else {
1780 panic!("expected the native parser diagnostic");
1781 };
1782 assert!(detail.to_string().contains("/man/tool.1"));
1783 assert!(
1784 detail
1785 .to_string()
1786 .contains("Unsupported: unsupported request")
1787 );
1788 assert_eq!(*host.calls.lock().expect("calls lock"), ["locate", "parse"]);
1789 }
1790
1791 #[test]
1792 fn requested_manual_section_failure_is_not_hidden_by_tldr() {
1793 let mut host = host(Err("libmandoc failed".to_owned()));
1794 host.locate = Err("section not found".to_owned());
1795 host.tldr = Ok(Some(tldr()));
1796 let request = QueryRequest {
1797 schema: RequestSchema::V7,
1798 input: QueryInput::Document {
1799 selector: "tool".to_owned(),
1800 source: None,
1801 manual_section: Some("7".to_owned()),
1802 },
1803 view: QueryView::Full {},
1804 };
1805
1806 let error = query_with(&request, QueryPolicy::default(), &host)
1807 .expect_err("an explicit section must require a native manual");
1808
1809 assert!(matches!(&error, QueryError::Manual(_)));
1810 assert!(error.to_string().contains("section not found"));
1811 assert_eq!(*host.calls.lock().expect("calls lock"), ["locate"]);
1812 }
1813
1814 #[test]
1815 fn truncated_unsupported_document_is_an_error_by_default() {
1816 let host = host(Ok(document(SourceFormat::Man, true, false)));
1817
1818 let QueryError::Manual(detail) = query_with(&request(), QueryPolicy::default(), &host)
1819 .expect_err("empty-section document must error by default")
1820 else {
1821 panic!("expected Manual error");
1822 };
1823 assert!(detail.to_string().contains("produced no readable sections"));
1824 }
1825
1826 #[test]
1827 fn readable_best_effort_document_survives_parser_findings() {
1828 let host = host(Ok(document(SourceFormat::Mdoc, true, true)));
1829 let result = query_with(&request(), QueryPolicy::default(), &host).expect("query");
1830 assert_eq!(
1831 result.document.expect("manual").source.format,
1832 SourceFormat::Mdoc
1833 );
1834 }
1835
1836 #[test]
1837 fn ordinary_query_reports_a_tldr_hint_after_total_document_failure() {
1838 let mut host = host(Err("libmandoc failed".to_owned()));
1839 host.locate = Err("source not found".to_owned());
1840 host.tldr = Ok(Some(tldr()));
1841 let error = query_with(&request(), QueryPolicy::default(), &host)
1842 .expect_err("ordinary query must require a full document");
1843
1844 assert!(matches!(error, QueryError::ManualWithTldr { .. }));
1845 assert_eq!(
1846 error.to_string(),
1847 "could not load manual 'tool': source not found\nhint: a tldr entry is available; run `mant tool --tldr`"
1848 );
1849 }
1850
1851 #[test]
1852 fn explicit_tldr_policy_survives_total_manual_failure() {
1853 let mut host = host(Err("libmandoc failed".to_owned()));
1854 host.locate = Err("source not found".to_owned());
1855 host.tldr = Ok(Some(tldr()));
1856 let result =
1857 query_with(&request(), QueryPolicy::TldrOnly, &host).expect("explicit tldr-only query");
1858
1859 assert!(result.document.is_none());
1860 assert_eq!(result.tldr.expect("tldr").title, "tool");
1861 }
1862
1863 #[test]
1864 fn positive_source_embedded_tldr_precedes_the_builtin_cache() {
1865 let mut host = host(Err("manual must not be read".to_owned()));
1866 host.registered_document = Some(PathBuf::from("/sources/team/tool.md"));
1867 host.registered_source_priority = Some(1);
1868 host.markdown = Ok(embedded_tldr_markdown());
1869 host.tldr = Ok(Some(tldr()));
1870
1871 let result = query_with(&request(), QueryPolicy::TldrOnly, &host)
1872 .expect("positive-priority embedded tldr");
1873
1874 assert_eq!(result.tldr.expect("tldr").origin, TldrOrigin::Embedded);
1875 assert_eq!(
1876 result.address,
1877 Some(DocumentAddress::Markdown {
1878 path: "tool".to_owned(),
1879 origin: MarkdownOrigin::Source {
1880 name: "team".to_owned(),
1881 },
1882 })
1883 );
1884 assert_eq!(*host.calls.lock().expect("calls"), ["name", "markdown"]);
1885 }
1886
1887 #[test]
1888 fn builtin_tldr_cache_wins_a_zero_priority_tie() {
1889 let mut host = host(Err("manual must not be read".to_owned()));
1890 host.registered_document = Some(PathBuf::from("/sources/team/tool.md"));
1891 host.registered_source_priority = Some(0);
1892 host.markdown = Ok(embedded_tldr_markdown());
1893 host.tldr = Ok(Some(tldr()));
1894
1895 let result =
1896 query_with(&request(), QueryPolicy::TldrOnly, &host).expect("builtin tldr cache");
1897
1898 assert_eq!(result.tldr.expect("tldr").origin, TldrOrigin::TldrPages);
1899 assert_eq!(*host.calls.lock().expect("calls"), ["name", "tldr"]);
1900 }
1901
1902 #[test]
1903 fn tldr_lookup_skips_markdown_without_an_embedded_quick_reference() {
1904 let mut host = host(Err("manual must not be read".to_owned()));
1905 host.registered_document = Some(PathBuf::from("/sources/team/tool.md"));
1906 host.registered_source_priority = Some(10);
1907 host.markdown = Ok("# Tool\n\nFull documentation only.\n".to_owned());
1908 host.tldr = Ok(Some(tldr()));
1909
1910 let result = query_with(&request(), QueryPolicy::TldrOnly, &host)
1911 .expect("cached tldr after empty Markdown candidate");
1912
1913 assert_eq!(result.tldr.expect("tldr").origin, TldrOrigin::TldrPages);
1914 assert_eq!(
1915 *host.calls.lock().expect("calls"),
1916 ["name", "markdown", "tldr"]
1917 );
1918 }
1919
1920 #[test]
1921 fn negative_source_embedded_tldr_is_the_final_fallback() {
1922 let mut host = host(Err("manual must not be read".to_owned()));
1923 host.registered_document = Some(PathBuf::from("/sources/team/tool.md"));
1924 host.registered_source_priority = Some(-1);
1925 host.markdown = Ok(embedded_tldr_markdown());
1926
1927 let result = query_with(&request(), QueryPolicy::TldrOnly, &host)
1928 .expect("negative-priority embedded tldr");
1929
1930 assert_eq!(result.tldr.expect("tldr").origin, TldrOrigin::Embedded);
1931 assert_eq!(
1932 *host.calls.lock().expect("calls"),
1933 ["name", "tldr", "fallback", "markdown"]
1934 );
1935 }
1936
1937 #[test]
1938 fn explicit_source_limits_tldr_lookup_to_that_source() {
1939 let mut host = host(Err("manual must not be read".to_owned()));
1940 host.registered_document = Some(PathBuf::from("/sources/team/tool.md"));
1941 host.registered_source_priority = Some(-1);
1942 host.markdown = Ok(embedded_tldr_markdown());
1943 host.tldr = Ok(Some(tldr()));
1944 let mut request = request();
1945 let QueryInput::Document { source, .. } = &mut request.input else {
1946 unreachable!("document request")
1947 };
1948 *source = Some("team".to_owned());
1949
1950 let result =
1951 query_with(&request, QueryPolicy::TldrOnly, &host).expect("source-owned embedded tldr");
1952
1953 assert_eq!(result.tldr.expect("tldr").origin, TldrOrigin::Embedded);
1954 assert_eq!(*host.calls.lock().expect("calls"), ["source", "markdown"]);
1955 }
1956
1957 #[test]
1958 fn reports_both_manual_paths_when_no_content_exists() {
1959 let mut host = host(Err("libmandoc failed".to_owned()));
1960 host.locate = Err("source not found".to_owned());
1961 let error = query_with(&request(), QueryPolicy::default(), &host)
1962 .expect_err("empty query must fail");
1963 assert_eq!(
1964 error.to_string(),
1965 "could not load manual 'tool': source not found"
1966 );
1967 }
1968
1969 #[test]
1970 fn validates_before_touching_host_state() {
1971 let host = host(Ok(document(SourceFormat::Man, false, true)));
1972 assert_eq!(
1973 query_with(
1974 &QueryRequest {
1975 schema: RequestSchema::V7,
1976 input: QueryInput::Document {
1977 selector: " ".to_owned(),
1978 source: None,
1979 manual_section: None,
1980 },
1981 view: QueryView::Full {},
1982 },
1983 QueryPolicy::default(),
1984 &host
1985 ),
1986 Err(QueryError::EmptyName)
1987 );
1988 assert!(host.calls.lock().expect("calls lock").is_empty());
1989 }
1990
1991 #[test]
1992 fn registered_markdown_shadows_an_unqualified_manual_name() {
1993 let mut host = host(Err("manual parser must not run".to_owned()));
1994 host.registered_document = Some(PathBuf::from("/data/mant/tool.md"));
1995 host.markdown = Ok("# Tool\n\n## Options\n\n- `--help`: Show help.\n".to_owned());
1996
1997 let result = query_with(&request(), QueryPolicy::default(), &host)
1998 .expect("registered Markdown name");
1999
2000 assert_eq!(result.label, "tool");
2001 assert!(result.tldr.is_none());
2002 let document = result.document.expect("registered document");
2003 assert_eq!(document.source.format, SourceFormat::Markdown);
2004 assert_eq!(document.source.path.as_deref(), Some("/data/mant/tool.md"));
2005 assert_eq!(
2006 *host.calls.lock().expect("calls lock"),
2007 ["name", "markdown"],
2008 "a registered name must not consult man or external tldr caches"
2009 );
2010 }
2011
2012 #[test]
2013 fn positive_source_priority_shadows_a_native_manual() {
2014 let mut host = host(Err("manual parser must not run".to_owned()));
2015 host.registered_document = Some(PathBuf::from("/sources/team/tool.md"));
2016 host.registered_source_priority = Some(1);
2017 host.markdown = Ok("# Team tool\n\nConfigured documentation.\n".to_owned());
2018
2019 let result = query_with(&request(), QueryPolicy::default(), &host)
2020 .expect("positive-priority Markdown");
2021
2022 assert_eq!(
2023 result.document.expect("document").source.format,
2024 SourceFormat::Markdown
2025 );
2026 assert_eq!(*host.calls.lock().expect("calls"), ["name", "markdown"]);
2027 }
2028
2029 #[test]
2030 fn native_manual_wins_a_zero_priority_tie() {
2031 let mut host = host(Ok(document(SourceFormat::Man, false, true)));
2032 host.registered_document = Some(PathBuf::from("/sources/team/tool.md"));
2033 host.registered_source_priority = Some(0);
2034 host.markdown = Ok("# Team tool\n\nConfigured documentation.\n".to_owned());
2035
2036 let result = query_with(&request(), QueryPolicy::default(), &host).expect("native manual");
2037
2038 assert_eq!(
2039 result.document.expect("document").source.format,
2040 SourceFormat::Man
2041 );
2042 assert_eq!(
2043 *host.calls.lock().expect("calls"),
2044 ["name", "tldr", "locate", "parse"]
2045 );
2046 }
2047
2048 #[test]
2049 fn non_positive_source_priority_falls_back_when_the_manual_is_unavailable() {
2050 let mut host = host(Err("manual parser must not run".to_owned()));
2051 host.locate = Err("source not found".to_owned());
2052 host.registered_document = Some(PathBuf::from("/sources/team/tool.md"));
2053 host.registered_source_priority = Some(-1);
2054 host.markdown = Ok("# Team tool\n\nConfigured documentation.\n".to_owned());
2055
2056 let result =
2057 query_with(&request(), QueryPolicy::default(), &host).expect("Markdown fallback");
2058
2059 assert_eq!(
2060 result.document.expect("document").source.format,
2061 SourceFormat::Markdown
2062 );
2063 assert_eq!(
2064 *host.calls.lock().expect("calls"),
2065 ["name", "tldr", "locate", "fallback", "markdown"]
2066 );
2067 }
2068
2069 #[test]
2070 fn windows_suffix_fallback_can_resolve_registered_markdown() {
2071 let mut host = host(Err("manual parser must not run".to_owned()));
2072 host.name_candidates = Some(vec!["tool".to_owned(), "tool.EXE".to_owned()]);
2073 host.registered_name = Some("tool.EXE".to_owned());
2074 host.registered_document = Some(PathBuf::from("/data/mant/tool.exe.md"));
2075 host.markdown = Ok("# Tool executable\n\nWindows command documentation.\n".to_owned());
2076
2077 let result = query_with(&request(), QueryPolicy::default(), &host)
2078 .expect("registered executable document");
2079
2080 assert_eq!(result.label, "tool");
2081 assert_eq!(
2082 result.document.expect("document").source.path.as_deref(),
2083 Some("/data/mant/tool.exe.md")
2084 );
2085 assert_eq!(
2086 *host.calls.lock().expect("calls lock"),
2087 ["name", "markdown"]
2088 );
2089 }
2090
2091 #[test]
2092 fn windows_suffix_fallback_can_resolve_a_native_manual() {
2093 let mut host = host(Ok(document(SourceFormat::Man, false, true)));
2094 host.name_candidates = Some(vec!["tool".to_owned(), "tool.EXE".to_owned()]);
2095 host.manual_name = Some("tool.EXE".to_owned());
2096 host.locate = Ok(ManualPage {
2097 name: "tool.exe".to_owned(),
2098 section: "1".to_owned(),
2099 path: PathBuf::from("/man/tool.exe.1"),
2100 manual_root: PathBuf::from("/man"),
2101 });
2102
2103 let result = query_with(&request(), QueryPolicy::default(), &host)
2104 .expect("native executable manual");
2105
2106 assert_eq!(result.label, "tool");
2107 assert_eq!(
2108 result.document.expect("manual").source.format,
2109 SourceFormat::Man
2110 );
2111 assert_eq!(
2112 *host.calls.lock().expect("calls lock"),
2113 ["name", "tldr", "locate", "locate", "parse"]
2114 );
2115 }
2116
2117 #[test]
2118 fn exact_names_win_before_windows_suffix_fallback() {
2119 let mut host = host(Err("manual parser must not run".to_owned()));
2120 host.name_candidates = Some(vec!["tool".to_owned(), "tool.EXE".to_owned()]);
2121 host.registered_document = Some(PathBuf::from("/data/mant/tool.md"));
2122 host.markdown = Ok("# Exact tool\n\nExact-name documentation.\n".to_owned());
2123
2124 let result = query_with(&request(), QueryPolicy::default(), &host)
2125 .expect("exact registered document");
2126
2127 assert_eq!(
2128 result.document.expect("document").source.path.as_deref(),
2129 Some("/data/mant/tool.md")
2130 );
2131 assert_eq!(
2132 *host.calls.lock().expect("calls lock"),
2133 ["name", "markdown"]
2134 );
2135 }
2136
2137 #[test]
2138 fn markdown_files_bypass_manual_and_tldr_sources() {
2139 let mut host = host(Err("manual parser must not run".to_owned()));
2140 host.markdown = Ok("# Tool\n\n## Options\n\n- `--help`: Show help.\n".to_owned());
2141 let result = query_with(
2142 &QueryRequest {
2143 schema: RequestSchema::V7,
2144 input: QueryInput::File {
2145 path: "docs/tool.md".to_owned(),
2146 format: InputFormat::Markdown,
2147 },
2148 view: QueryView::Full {},
2149 },
2150 QueryPolicy::default(),
2151 &host,
2152 )
2153 .expect("Markdown query");
2154
2155 assert_eq!(result.label, "tool.md");
2156 assert!(result.tldr.is_none());
2157 let document = result.document.expect("document");
2158 assert_eq!(document.source.format, SourceFormat::Markdown);
2159 assert_eq!(document.source.path.as_deref(), Some("docs/tool.md"));
2160 assert_eq!(
2161 *host.calls.lock().expect("calls lock"),
2162 ["markdown"],
2163 "Markdown must not consult man or tldr"
2164 );
2165 }
2166
2167 #[test]
2168 fn in_memory_markdown_is_available_without_a_protocol_content_field() {
2169 let result = query_markdown_text("# Piped\n\nBody.\n", None).expect("stdin Markdown query");
2170
2171 assert_eq!(result.label, "stdin");
2172 assert!(result.tldr.is_none());
2173 let document = result.document.expect("document");
2174 assert_eq!(document.meta.title.as_deref(), Some("Piped"));
2175 assert_eq!(document.source.path, None);
2176 }
2177
2178 #[test]
2179 fn leading_tldr_directives_are_independent_from_the_markdown_document() {
2180 let source = "\
2181<!-- mant:tldr:start -->
2182# demo
2183
2184> Concise embedded help.
2185
2186- Run the demo:
2187
2188`demo {{path}}`
2189<!-- mant:tldr:end -->
2190
2191# Demo
2192
2193Document overview.
2194
2195## Options
2196
2197- `--help`: Show help.
2198";
2199 let result =
2200 query_markdown_text(source, Some("docs/demo.md".to_owned())).expect("Markdown query");
2201
2202 let tldr = result.tldr.expect("embedded tldr");
2203 assert_eq!(tldr.title, "demo");
2204 assert_eq!(tldr.origin, TldrOrigin::Embedded);
2205 assert_eq!(tldr.source_path, "docs/demo.md");
2206 assert_eq!(tldr.examples[0].command, "demo {{path}}");
2207
2208 let document = result.document.expect("document body");
2209 assert_eq!(document.meta.title.as_deref(), Some("Demo"));
2210 assert_eq!(document.sections[0].title, "Options");
2211 assert!(
2212 document
2213 .blocks
2214 .iter()
2215 .any(|block| matches!(block, mant_ir::Block::Paragraph { .. }))
2216 );
2217 assert!(
2218 document
2219 .diagnostics
2220 .iter()
2221 .all(|diagnostic| !diagnostic.message.contains("mant:tldr"))
2222 );
2223 }
2224
2225 #[test]
2226 fn malformed_leading_tldr_directives_report_the_source_path() {
2227 let error = query_markdown_text(
2228 "<!-- mant:tldr:start -->\n# demo\n\n- Run:\n\n`demo`\n",
2229 Some("docs/broken.md".to_owned()),
2230 )
2231 .expect_err("unterminated directive");
2232
2233 assert_eq!(
2234 error.to_string(),
2235 "could not load Markdown document 'docs/broken.md': top-level <!-- mant:tldr:start --> marker is missing its <!-- mant:tldr:end --> marker"
2236 );
2237 }
2238
2239 #[test]
2240 fn capped_read_accepts_input_up_to_the_limit() {
2241 let source = "abcd";
2242 assert_eq!(
2243 read_capped_utf8(source.as_bytes(), source.len() as u64).expect("within limit"),
2244 source
2245 );
2246 }
2247
2248 #[test]
2249 fn capped_read_rejects_input_past_the_limit_without_buffering_it_whole() {
2250 let error = read_capped_utf8(io::repeat(b'a'), 8).expect_err("over limit");
2253 assert!(error.contains("exceeds the 8-byte limit"), "{error}");
2254 }
2255
2256 #[test]
2257 fn capped_read_rejects_non_utf8_input() {
2258 let error =
2259 read_capped_utf8(&[0xff, 0xfe][..], MAX_MARKDOWN_BYTES).expect_err("invalid UTF-8");
2260 assert!(error.contains("must be UTF-8"), "{error}");
2261 }
2262
2263 #[test]
2264 fn capped_io_read_preserves_the_underlying_error_kind() {
2265 struct PermissionDeniedReader;
2266
2267 impl io::Read for PermissionDeniedReader {
2268 fn read(&mut self, _buffer: &mut [u8]) -> io::Result<usize> {
2269 Err(io::Error::new(
2270 io::ErrorKind::PermissionDenied,
2271 "reader denied access",
2272 ))
2273 }
2274 }
2275
2276 let error = read_capped_utf8_io(PermissionDeniedReader, MAX_MARKDOWN_BYTES)
2277 .expect_err("reader failure is preserved");
2278 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
2279 assert_eq!(error.to_string(), "reader denied access");
2280 }
2281}