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 = host.read_tldr(name).ok().flatten();
619 let mut manual = load_manual(name, &candidates, section.as_deref(), host);
620
621 if let (Ok(document), Some(section)) = (&mut manual, section.as_deref())
624 && document.meta.section.is_none()
625 {
626 document.meta.section = Some(section.to_owned());
627 }
628
629 if require_manual {
632 return match manual {
633 Ok(manual) => Ok(QueryBundle {
634 schema: QuerySchema::V6,
635 label: name.to_owned(),
636 document: Some(manual),
637 tldr,
638 }),
639 Err(error) => Err(QueryError::Manual(error)),
640 };
641 }
642
643 match manual {
644 Ok(manual) => Ok(QueryBundle {
645 schema: QuerySchema::V6,
646 label: name.to_owned(),
647 document: Some(manual),
648 tldr,
649 }),
650 Err(_) if tldr.is_some() => Ok(QueryBundle {
651 schema: QuerySchema::V6,
652 label: name.to_owned(),
653 document: None,
654 tldr,
655 }),
656 Err(error) => Err(QueryError::Manual(error)),
657 }
658}
659
660fn query_registered_document(
661 name: &str,
662 path: &Path,
663 host: &dyn QueryHost,
664) -> Result<QueryBundle, QueryError> {
665 let source_path = path.to_string_lossy().into_owned();
666 let source = host
667 .read_markdown(path)
668 .map_err(|detail| QueryError::Markdown {
669 path: source_path.clone(),
670 detail,
671 })?;
672 let mut query = query_markdown_text(&source, Some(source_path))?;
673 name.clone_into(&mut query.label);
674 Ok(query)
675}
676
677fn load_manual(
678 requested_name: &str,
679 candidates: &[String],
680 section: Option<&str>,
681 host: &dyn QueryHost,
682) -> Result<MantDocument, ManualLoadError> {
683 let mut first_locate_error = None;
684 let mut located = None;
685 for candidate in candidates {
686 let request = ManualRequest::new(candidate, section.map(ToOwned::to_owned));
687 match host.locate_manual(&request) {
688 Ok(page) => {
689 located = Some(page);
690 break;
691 }
692 Err(error) => {
693 first_locate_error.get_or_insert(error);
694 }
695 }
696 }
697 let Some(page) = located else {
698 let error =
699 first_locate_error.unwrap_or_else(|| "no name candidates were available".to_owned());
700 return Err(ManualLoadError::NotFound {
701 name: requested_name.to_owned(),
702 detail: error,
703 });
704 };
705
706 let source_path = page.path.clone();
707 let document = host
708 .parse_manual(&page)
709 .map_err(|detail| ManualLoadError::Parse {
710 name: requested_name.to_owned(),
711 detail,
712 })?;
713 if document.sections.is_empty() {
714 let diagnostics = document
715 .diagnostics
716 .iter()
717 .map(|diagnostic| {
718 let location = diagnostic.source.map_or_else(String::new, |source| {
719 format!(" at {}:{}", source.line, source.column)
720 });
721 format!("{:?}{location}: {}", diagnostic.level, diagnostic.message)
722 })
723 .collect::<Vec<_>>();
724 return Err(ManualLoadError::Empty {
725 name: requested_name.to_owned(),
726 path: source_path,
727 diagnostics,
728 });
729 }
730 Ok(document)
731}
732
733#[cfg(test)]
734mod tests {
735 use std::{
736 io,
737 path::{Path, PathBuf},
738 sync::Mutex,
739 };
740
741 use mant_ast::{
742 Diagnostic, DiagnosticLevel, DocumentMeta, DocumentSchema, DocumentSource, MantDocument,
743 Producer, QueryInput, QueryRequest, QueryView, RequestSchema, Section, SourceFormat,
744 TldrDocument, TldrOrigin,
745 };
746
747 use crate::{ManualPage, ManualRequest};
748
749 use super::{
750 MAX_MARKDOWN_BYTES, QueryError, QueryHost, QueryPolicy, query_markdown_text, query_with,
751 read_capped_utf8, read_capped_utf8_io,
752 };
753
754 #[derive(Clone)]
755 struct StubHost {
756 name_candidates: Option<Vec<String>>,
757 registered_document: Option<PathBuf>,
758 registered_name: Option<String>,
759 locate: Result<ManualPage, String>,
760 manual_name: Option<String>,
761 direct: Result<MantDocument, String>,
762 tldr: Result<Option<TldrDocument>, String>,
763 markdown: Result<String, String>,
764 calls: std::sync::Arc<Mutex<Vec<&'static str>>>,
765 }
766
767 impl QueryHost for StubHost {
768 fn name_candidates(&self, name: &str) -> Vec<String> {
769 self.name_candidates
770 .clone()
771 .unwrap_or_else(|| vec![name.to_owned()])
772 }
773
774 fn locate_registered_document(
775 &self,
776 candidates: &[String],
777 source: Option<&str>,
778 ) -> Result<Option<PathBuf>, String> {
779 self.calls
780 .lock()
781 .expect("calls lock")
782 .push(if source.is_some() { "source" } else { "name" });
783 if self
784 .registered_name
785 .as_deref()
786 .is_some_and(|registered_name| {
787 !candidates
788 .iter()
789 .any(|candidate| candidate == registered_name)
790 })
791 {
792 return Ok(None);
793 }
794 Ok(self.registered_document.clone())
795 }
796
797 fn locate_manual(&self, request: &ManualRequest) -> Result<ManualPage, String> {
798 self.calls.lock().expect("calls lock").push("locate");
799 if self
800 .manual_name
801 .as_deref()
802 .is_some_and(|manual_name| manual_name != request.name)
803 {
804 return Err("source not found".to_owned());
805 }
806 self.locate.clone()
807 }
808
809 fn parse_manual(&self, _page: &ManualPage) -> Result<MantDocument, String> {
810 self.calls.lock().expect("calls lock").push("parse");
811 self.direct.clone()
812 }
813
814 fn read_tldr(&self, _name: &str) -> Result<Option<TldrDocument>, String> {
815 self.calls.lock().expect("calls lock").push("tldr");
816 self.tldr.clone()
817 }
818
819 fn read_markdown(&self, _path: &Path) -> Result<String, String> {
820 self.calls.lock().expect("calls lock").push("markdown");
821 self.markdown.clone()
822 }
823 }
824
825 fn document(format: SourceFormat, unsupported: bool, readable: bool) -> MantDocument {
826 MantDocument {
827 schema: DocumentSchema::V6,
828 producer: Producer {
829 name: "test".to_owned(),
830 version: "1".to_owned(),
831 engine: None,
832 },
833 source: DocumentSource { format, path: None },
834 meta: DocumentMeta::default(),
835 diagnostics: unsupported
836 .then_some(Diagnostic {
837 level: DiagnosticLevel::Unsupported,
838 code: None,
839 message: "unsupported request".to_owned(),
840 source: None,
841 })
842 .into_iter()
843 .collect(),
844 blocks: Vec::new(),
845 sections: readable
846 .then_some(Section {
847 id: "name-1".to_owned(),
848 title: "NAME".to_owned(),
849 spacing_before_lines: 0,
850 blocks: Vec::new(),
851 children: Vec::new(),
852 source: None,
853 })
854 .into_iter()
855 .collect(),
856 }
857 }
858
859 fn tldr() -> TldrDocument {
860 TldrDocument {
861 title: "tool".to_owned(),
862 description: vec!["quick reference".to_owned()],
863 more_information: None,
864 examples: Vec::new(),
865 platform: "common".to_owned(),
866 language: "en".to_owned(),
867 source_path: "/cache/pages/common/tool.md".to_owned(),
868 origin: TldrOrigin::TldrPages,
869 }
870 }
871
872 fn host(direct: Result<MantDocument, String>) -> StubHost {
873 StubHost {
874 name_candidates: None,
875 registered_document: None,
876 registered_name: None,
877 locate: Ok(ManualPage {
878 name: "tool".to_owned(),
879 section: "1".to_owned(),
880 path: PathBuf::from("/man/tool.1"),
881 manual_root: PathBuf::from("/man"),
882 }),
883 manual_name: None,
884 direct,
885 tldr: Ok(None),
886 markdown: Err("Markdown unavailable".to_owned()),
887 calls: std::sync::Arc::default(),
888 }
889 }
890
891 fn request() -> QueryRequest {
892 QueryRequest {
893 schema: RequestSchema::V6,
894 input: QueryInput::Document {
895 name: " tool ".to_owned(),
896 source: None,
897 section: None,
898 },
899 view: QueryView::Full {},
900 }
901 }
902
903 #[test]
904 fn ordinary_manual_uses_the_native_parser() {
905 let host = host(Ok(document(SourceFormat::Man, false, true)));
906 let result = query_with(&request(), QueryPolicy::default(), &host).expect("query");
907
908 assert_eq!(result.label, "tool");
909 assert_eq!(
910 result.document.expect("manual").source.format,
911 SourceFormat::Man
912 );
913 assert_eq!(
914 *host.calls.lock().expect("calls lock"),
915 ["name", "tldr", "locate", "parse"]
916 );
917 }
918
919 #[test]
920 fn requested_section_backfills_metadata_the_parser_left_empty() {
921 let host = host(Ok(document(SourceFormat::Man, false, true)));
922 let request = QueryRequest {
923 schema: RequestSchema::V6,
924 input: QueryInput::Document {
925 name: "tool".to_owned(),
926 source: None,
927 section: Some("3".to_owned()),
928 },
929 view: QueryView::Full {},
930 };
931
932 let result = query_with(&request, QueryPolicy::default(), &host).expect("query");
933 assert_eq!(
934 result.document.expect("manual").meta.section.as_deref(),
935 Some("3"),
936 "requested section must label output when the parser omits it"
937 );
938 assert_eq!(
939 *host.calls.lock().expect("calls lock"),
940 ["tldr", "locate", "parse"],
941 "an explicit manual section bypasses registered Markdown"
942 );
943 }
944
945 #[test]
946 fn explicit_source_reads_only_registered_markdown() {
947 let mut host = host(Err("manual must not be read".to_owned()));
948 host.registered_document = Some(PathBuf::from("/documents/tool.md"));
949 host.markdown = Ok("# Tool\n\nSource body.\n".to_owned());
950 let request = QueryRequest {
951 schema: RequestSchema::V6,
952 input: QueryInput::Document {
953 name: "tool".to_owned(),
954 source: Some("team".to_owned()),
955 section: None,
956 },
957 view: QueryView::Full {},
958 };
959 let result = query_with(&request, QueryPolicy::default(), &host).expect("source query");
960 assert_eq!(
961 result.document.expect("Markdown").meta.title.as_deref(),
962 Some("Tool")
963 );
964 assert_eq!(
965 *host.calls.lock().expect("calls lock"),
966 ["source", "markdown"]
967 );
968 }
969
970 #[test]
971 fn complete_direct_document_survives_an_unsupported_finding() {
972 let host = host(Ok(document(SourceFormat::Man, true, true)));
973 let result = query_with(&request(), QueryPolicy::default(), &host).expect("query");
974
975 assert_eq!(
976 result.document.expect("manual").source.format,
977 SourceFormat::Man
978 );
979 assert_eq!(
980 *host.calls.lock().expect("calls lock"),
981 ["name", "tldr", "locate", "parse"]
982 );
983 }
984
985 #[test]
986 fn manual_only_bypasses_registered_markdown() {
987 let mut host = host(Ok(document(SourceFormat::Man, true, true)));
988 host.registered_document = Some(PathBuf::from("/data/mant/tool.md"));
989 host.markdown = Ok("# Registered".to_owned());
990 let result = query_with(&request(), QueryPolicy { manual_only: true }, &host)
991 .expect("manual-only query");
992
993 assert_eq!(
994 result.document.expect("manual").source.format,
995 SourceFormat::Man
996 );
997 assert_eq!(
998 *host.calls.lock().expect("calls lock"),
999 ["tldr", "locate", "parse"],
1000 "manual-only lookup must not inspect the registered-document namespace"
1001 );
1002 }
1003
1004 #[test]
1005 fn manual_only_failure_is_not_hidden_by_tldr() {
1006 let mut host = host(Ok(document(SourceFormat::Man, true, false)));
1007 host.tldr = Ok(Some(tldr()));
1008
1009 let error = query_with(&request(), QueryPolicy { manual_only: true }, &host)
1010 .expect_err("an optional tldr page must not hide native parser failure");
1011
1012 let QueryError::Manual(detail) = error else {
1013 panic!("expected the native parser diagnostic");
1014 };
1015 assert!(detail.to_string().contains("/man/tool.1"));
1016 assert!(
1017 detail
1018 .to_string()
1019 .contains("Unsupported: unsupported request")
1020 );
1021 assert_eq!(
1022 *host.calls.lock().expect("calls lock"),
1023 ["tldr", "locate", "parse"]
1024 );
1025 }
1026
1027 #[test]
1028 fn requested_section_failure_is_not_hidden_by_tldr() {
1029 let mut host = host(Err("libmandoc failed".to_owned()));
1030 host.locate = Err("section not found".to_owned());
1031 host.tldr = Ok(Some(tldr()));
1032 let request = QueryRequest {
1033 schema: RequestSchema::V6,
1034 input: QueryInput::Document {
1035 name: "tool".to_owned(),
1036 source: None,
1037 section: Some("7".to_owned()),
1038 },
1039 view: QueryView::Full {},
1040 };
1041
1042 let error = query_with(&request, QueryPolicy::default(), &host)
1043 .expect_err("an explicit section must require a native manual");
1044
1045 assert!(matches!(&error, QueryError::Manual(_)));
1046 assert!(error.to_string().contains("section not found"));
1047 assert_eq!(*host.calls.lock().expect("calls lock"), ["tldr", "locate"]);
1048 }
1049
1050 #[test]
1051 fn truncated_unsupported_document_is_an_error_by_default() {
1052 let host = host(Ok(document(SourceFormat::Man, true, false)));
1053
1054 let QueryError::Manual(detail) = query_with(&request(), QueryPolicy::default(), &host)
1055 .expect_err("empty-section document must error by default")
1056 else {
1057 panic!("expected Manual error");
1058 };
1059 assert!(detail.to_string().contains("produced no readable sections"));
1060 }
1061
1062 #[test]
1063 fn readable_best_effort_document_survives_parser_findings() {
1064 let host = host(Ok(document(SourceFormat::Mdoc, true, true)));
1065 let result = query_with(&request(), QueryPolicy::default(), &host).expect("query");
1066 assert_eq!(
1067 result.document.expect("manual").source.format,
1068 SourceFormat::Mdoc
1069 );
1070 }
1071
1072 #[test]
1073 fn cached_tldr_survives_total_manual_failure() {
1074 let mut host = host(Err("libmandoc failed".to_owned()));
1075 host.locate = Err("source not found".to_owned());
1076 host.tldr = Ok(Some(tldr()));
1077 let result =
1078 query_with(&request(), QueryPolicy::default(), &host).expect("tldr-only query");
1079
1080 assert!(result.document.is_none());
1081 assert_eq!(result.tldr.expect("tldr").title, "tool");
1082 }
1083
1084 #[test]
1085 fn reports_both_manual_paths_when_no_content_exists() {
1086 let mut host = host(Err("libmandoc failed".to_owned()));
1087 host.locate = Err("source not found".to_owned());
1088 let error = query_with(&request(), QueryPolicy::default(), &host)
1089 .expect_err("empty query must fail");
1090 assert_eq!(
1091 error.to_string(),
1092 "could not load manual 'tool': manual source: source not found"
1093 );
1094 }
1095
1096 #[test]
1097 fn validates_before_touching_host_state() {
1098 let host = host(Ok(document(SourceFormat::Man, false, true)));
1099 assert_eq!(
1100 query_with(
1101 &QueryRequest {
1102 schema: RequestSchema::V6,
1103 input: QueryInput::Document {
1104 name: " ".to_owned(),
1105 source: None,
1106 section: None,
1107 },
1108 view: QueryView::Full {},
1109 },
1110 QueryPolicy::default(),
1111 &host
1112 ),
1113 Err(QueryError::EmptyName)
1114 );
1115 assert!(host.calls.lock().expect("calls lock").is_empty());
1116 }
1117
1118 #[test]
1119 fn registered_markdown_shadows_an_unqualified_manual_name() {
1120 let mut host = host(Err("manual parser must not run".to_owned()));
1121 host.registered_document = Some(PathBuf::from("/data/mant/tool.md"));
1122 host.markdown = Ok("# Tool\n\n## Options\n\n- `--help`: Show help.\n".to_owned());
1123
1124 let result = query_with(&request(), QueryPolicy::default(), &host)
1125 .expect("registered Markdown name");
1126
1127 assert_eq!(result.label, "tool");
1128 assert!(result.tldr.is_none());
1129 let document = result.document.expect("registered document");
1130 assert_eq!(document.source.format, SourceFormat::Markdown);
1131 assert_eq!(document.source.path.as_deref(), Some("/data/mant/tool.md"));
1132 assert_eq!(
1133 *host.calls.lock().expect("calls lock"),
1134 ["name", "markdown"],
1135 "a registered name must not consult man or external tldr caches"
1136 );
1137 }
1138
1139 #[test]
1140 fn windows_suffix_fallback_can_resolve_registered_markdown() {
1141 let mut host = host(Err("manual parser must not run".to_owned()));
1142 host.name_candidates = Some(vec!["tool".to_owned(), "tool.EXE".to_owned()]);
1143 host.registered_name = Some("tool.EXE".to_owned());
1144 host.registered_document = Some(PathBuf::from("/data/mant/tool.exe.md"));
1145 host.markdown = Ok("# Tool executable\n\nWindows command documentation.\n".to_owned());
1146
1147 let result = query_with(&request(), QueryPolicy::default(), &host)
1148 .expect("registered executable document");
1149
1150 assert_eq!(result.label, "tool");
1151 assert_eq!(
1152 result.document.expect("document").source.path.as_deref(),
1153 Some("/data/mant/tool.exe.md")
1154 );
1155 assert_eq!(
1156 *host.calls.lock().expect("calls lock"),
1157 ["name", "markdown"]
1158 );
1159 }
1160
1161 #[test]
1162 fn windows_suffix_fallback_can_resolve_a_native_manual() {
1163 let mut host = host(Ok(document(SourceFormat::Man, false, true)));
1164 host.name_candidates = Some(vec!["tool".to_owned(), "tool.EXE".to_owned()]);
1165 host.manual_name = Some("tool.EXE".to_owned());
1166 host.locate = Ok(ManualPage {
1167 name: "tool.exe".to_owned(),
1168 section: "1".to_owned(),
1169 path: PathBuf::from("/man/tool.exe.1"),
1170 manual_root: PathBuf::from("/man"),
1171 });
1172
1173 let result = query_with(&request(), QueryPolicy::default(), &host)
1174 .expect("native executable manual");
1175
1176 assert_eq!(result.label, "tool");
1177 assert_eq!(
1178 result.document.expect("manual").source.format,
1179 SourceFormat::Man
1180 );
1181 assert_eq!(
1182 *host.calls.lock().expect("calls lock"),
1183 ["name", "tldr", "locate", "locate", "parse"]
1184 );
1185 }
1186
1187 #[test]
1188 fn exact_names_win_before_windows_suffix_fallback() {
1189 let mut host = host(Err("manual parser must not run".to_owned()));
1190 host.name_candidates = Some(vec!["tool".to_owned(), "tool.EXE".to_owned()]);
1191 host.registered_document = Some(PathBuf::from("/data/mant/tool.md"));
1192 host.markdown = Ok("# Exact tool\n\nExact-name documentation.\n".to_owned());
1193
1194 let result = query_with(&request(), QueryPolicy::default(), &host)
1195 .expect("exact registered document");
1196
1197 assert_eq!(
1198 result.document.expect("document").source.path.as_deref(),
1199 Some("/data/mant/tool.md")
1200 );
1201 assert_eq!(
1202 *host.calls.lock().expect("calls lock"),
1203 ["name", "markdown"]
1204 );
1205 }
1206
1207 #[test]
1208 fn markdown_files_bypass_manual_and_tldr_sources() {
1209 let mut host = host(Err("manual parser must not run".to_owned()));
1210 host.markdown = Ok("# Tool\n\n## Options\n\n- `--help`: Show help.\n".to_owned());
1211 let result = query_with(
1212 &QueryRequest {
1213 schema: RequestSchema::V6,
1214 input: QueryInput::MarkdownFile {
1215 path: "docs/tool.md".to_owned(),
1216 },
1217 view: QueryView::Full {},
1218 },
1219 QueryPolicy::default(),
1220 &host,
1221 )
1222 .expect("Markdown query");
1223
1224 assert_eq!(result.label, "tool.md");
1225 assert!(result.tldr.is_none());
1226 let document = result.document.expect("document");
1227 assert_eq!(document.source.format, SourceFormat::Markdown);
1228 assert_eq!(document.source.path.as_deref(), Some("docs/tool.md"));
1229 assert_eq!(
1230 *host.calls.lock().expect("calls lock"),
1231 ["markdown"],
1232 "Markdown must not consult man or tldr"
1233 );
1234 }
1235
1236 #[test]
1237 fn in_memory_markdown_is_available_without_a_protocol_content_field() {
1238 let result = query_markdown_text("# Piped\n\nBody.\n", None).expect("stdin Markdown query");
1239
1240 assert_eq!(result.label, "stdin");
1241 assert!(result.tldr.is_none());
1242 let document = result.document.expect("document");
1243 assert_eq!(document.meta.title.as_deref(), Some("Piped"));
1244 assert_eq!(document.source.path, None);
1245 }
1246
1247 #[test]
1248 fn leading_tldr_directives_are_independent_from_the_markdown_document() {
1249 let source = "\
1250<!-- mant:tldr:start -->
1251# demo
1252
1253> Concise embedded help.
1254
1255- Run the demo:
1256
1257`demo {{path}}`
1258<!-- mant:tldr:end -->
1259
1260# Demo
1261
1262Document overview.
1263
1264## Options
1265
1266- `--help`: Show help.
1267";
1268 let result =
1269 query_markdown_text(source, Some("docs/demo.md".to_owned())).expect("Markdown query");
1270
1271 let tldr = result.tldr.expect("embedded tldr");
1272 assert_eq!(tldr.title, "demo");
1273 assert_eq!(tldr.origin, TldrOrigin::Embedded);
1274 assert_eq!(tldr.source_path, "docs/demo.md");
1275 assert_eq!(tldr.examples[0].command, "demo {{path}}");
1276
1277 let document = result.document.expect("document body");
1278 assert_eq!(document.meta.title.as_deref(), Some("Demo"));
1279 assert_eq!(document.sections[0].title, "Options");
1280 assert!(
1281 document
1282 .blocks
1283 .iter()
1284 .any(|block| matches!(block, mant_ast::Block::Paragraph { .. }))
1285 );
1286 assert!(
1287 document
1288 .diagnostics
1289 .iter()
1290 .all(|diagnostic| !diagnostic.message.contains("mant:tldr"))
1291 );
1292 }
1293
1294 #[test]
1295 fn malformed_leading_tldr_directives_report_the_source_path() {
1296 let error = query_markdown_text(
1297 "<!-- mant:tldr:start -->\n# demo\n\n- Run:\n\n`demo`\n",
1298 Some("docs/broken.md".to_owned()),
1299 )
1300 .expect_err("unterminated directive");
1301
1302 assert_eq!(
1303 error.to_string(),
1304 "could not load Markdown document 'docs/broken.md': top-level <!-- mant:tldr:start --> marker is missing its <!-- mant:tldr:end --> marker"
1305 );
1306 }
1307
1308 #[test]
1309 fn capped_read_accepts_input_up_to_the_limit() {
1310 let source = "abcd";
1311 assert_eq!(
1312 read_capped_utf8(source.as_bytes(), source.len() as u64).expect("within limit"),
1313 source
1314 );
1315 }
1316
1317 #[test]
1318 fn capped_read_rejects_input_past_the_limit_without_buffering_it_whole() {
1319 let error = read_capped_utf8(io::repeat(b'a'), 8).expect_err("over limit");
1322 assert!(error.contains("exceeds the 8-byte limit"), "{error}");
1323 }
1324
1325 #[test]
1326 fn capped_read_rejects_non_utf8_input() {
1327 let error =
1328 read_capped_utf8(&[0xff, 0xfe][..], MAX_MARKDOWN_BYTES).expect_err("invalid UTF-8");
1329 assert!(error.contains("must be UTF-8"), "{error}");
1330 }
1331
1332 #[test]
1333 fn capped_io_read_preserves_the_underlying_error_kind() {
1334 struct PermissionDeniedReader;
1335
1336 impl io::Read for PermissionDeniedReader {
1337 fn read(&mut self, _buffer: &mut [u8]) -> io::Result<usize> {
1338 Err(io::Error::new(
1339 io::ErrorKind::PermissionDenied,
1340 "reader denied access",
1341 ))
1342 }
1343 }
1344
1345 let error = read_capped_utf8_io(PermissionDeniedReader, MAX_MARKDOWN_BYTES)
1346 .expect_err("reader failure is preserved");
1347 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
1348 assert_eq!(error.to_string(), "reader denied access");
1349 }
1350}