1use std::{collections::BTreeSet, error::Error, fmt, path::PathBuf};
4
5use grep_matcher::Matcher;
6use grep_regex::RegexMatcherBuilder;
7use mant_protocol::{
8 CatalogCoverage, CatalogDocumentKind, CatalogMatchScore, CatalogQuery, CatalogSchema,
9 DocumentAddress, DocumentCatalog, DocumentSummary, MarkdownOrigin, SearchCase, SearchSyntax,
10};
11
12use mant_sources::{
13 BUILTIN_CONTENT_PRIORITY, RegisteredDocument, RegisteredDocumentOrigin, SourceConfigError,
14 list_registered_documents,
15};
16
17use crate::{ManualIndex, discover_manual_roots};
18
19#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
21pub enum AvailableDocumentKind {
22 Markdown,
24 Manual,
26}
27
28#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
30pub enum AvailableDocumentOrigin {
31 Documents,
33 Source(String),
35 ManualPath,
37}
38
39#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct AvailableDocument {
42 pub name: String,
44 pub logical_path: String,
46 pub kind: AvailableDocumentKind,
48 pub manual_section: Option<String>,
50 pub path: PathBuf,
52 pub origin: AvailableDocumentOrigin,
54 pub source_priority: Option<i32>,
56}
57
58#[derive(Clone, Debug, Eq, PartialEq)]
60pub enum CatalogError {
61 EmptyPattern,
63 PatternTooLong,
65 InvalidLimit,
67 ConflictingSelectors,
69 InvalidPattern(String),
71}
72
73impl fmt::Display for CatalogError {
74 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
75 match self {
76 Self::EmptyPattern => formatter.write_str("catalog pattern must not be empty"),
77 Self::PatternTooLong => {
78 formatter.write_str("catalog pattern exceeds the 4096-byte limit")
79 }
80 Self::InvalidLimit => formatter.write_str("catalog limit must be between 1 and 10000"),
81 Self::ConflictingSelectors => {
82 formatter.write_str("catalog source and manual-section filters cannot be combined")
83 }
84 Self::InvalidPattern(message) => {
85 write!(formatter, "invalid catalog pattern: {message}")
86 }
87 }
88 }
89}
90
91impl Error for CatalogError {}
92
93pub fn list_available_documents() -> Result<Vec<AvailableDocument>, SourceConfigError> {
100 let manuals = ManualIndex::from_roots(discover_manual_roots());
101 Ok(list_available_documents_from(
102 list_registered_documents()?,
103 manuals.pages(),
104 ))
105}
106
107pub fn query_available_documents(
113 documents: &[AvailableDocument],
114 query: &CatalogQuery,
115) -> Result<DocumentCatalog, CatalogError> {
116 validate_catalog_query(query)?;
117 let compiled_pattern = query
118 .pattern
119 .as_deref()
120 .map(|pattern| build_matcher(pattern, query.syntax, query.case))
121 .transpose()?;
122 let in_scope = |document: &&AvailableDocument| catalog_scope_matches(document, query);
123 let scope_total = documents.iter().filter(in_scope).count();
124 let mut filtered = documents
125 .iter()
126 .filter(in_scope)
127 .filter_map(|document| {
128 let match_catalog_path = query
129 .pattern
130 .as_deref()
131 .is_some_and(|pattern| pattern.contains('/'));
132 let matched = compiled_pattern.as_ref().map_or(Ok(true), |matcher| {
133 matcher
134 .is_match(document.name.as_bytes())
135 .and_then(|matched| {
136 if matched {
137 Ok(true)
138 } else {
139 matcher.is_match(document.logical_path.as_bytes())
140 }
141 })
142 .and_then(|matched| {
143 if matched {
144 Ok(true)
145 } else if !match_catalog_path {
146 Ok(false)
147 } else {
148 matcher.is_match(available_catalog_path(document).as_bytes())
149 }
150 })
151 });
152 matched.ok().filter(|matched| *matched).map(|_| document)
153 })
154 .collect::<Vec<_>>();
155 filtered.sort_by(|left, right| {
156 match_score(left, query)
157 .cmp(&match_score(right, query))
158 .then_with(|| {
159 left.logical_path
160 .to_lowercase()
161 .cmp(&right.logical_path.to_lowercase())
162 })
163 .then_with(|| left.logical_path.cmp(&right.logical_path))
164 .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
165 .then_with(|| left.name.cmp(&right.name))
166 .then_with(|| compare_precedence(left, right))
167 .then_with(|| left.manual_section.cmp(&right.manual_section))
168 .then_with(|| left.origin.cmp(&right.origin))
169 });
170
171 let total = filtered.len();
172 let offset = usize::try_from(query.offset)
173 .unwrap_or(usize::MAX)
174 .min(total);
175 let limit = usize::try_from(query.limit).unwrap_or(usize::MAX);
176 let end = offset.saturating_add(limit).min(total);
177 let coverage = catalog_coverage(documents, scope_total);
178 let documents = filtered[offset..end]
179 .iter()
180 .copied()
181 .map(document_summary)
182 .collect::<Vec<_>>();
183 Ok(DocumentCatalog {
184 schema: CatalogSchema::V0Dot9,
185 query: query.clone(),
186 coverage,
187 total: u32::try_from(total).unwrap_or(u32::MAX),
188 returned: u32::try_from(documents.len()).unwrap_or(u32::MAX),
189 offset: u32::try_from(offset).unwrap_or(u32::MAX),
190 truncated: end < total,
191 next_offset: (end < total).then(|| u32::try_from(end).unwrap_or(u32::MAX)),
192 documents,
193 })
194}
195
196fn catalog_scope_matches(document: &AvailableDocument, query: &CatalogQuery) -> bool {
197 query.kind.is_none_or(|kind| match kind {
198 CatalogDocumentKind::Markdown => document.kind == AvailableDocumentKind::Markdown,
199 CatalogDocumentKind::Manual => document.kind == AvailableDocumentKind::Manual,
200 }) && query.manual_section.as_ref().is_none_or(|section| {
201 document
202 .manual_section
203 .as_ref()
204 .is_some_and(|value| value == section)
205 }) && query.source.as_ref().is_none_or(|source| {
206 matches!(&document.origin, AvailableDocumentOrigin::Source(value) if value == source)
207 })
208}
209
210fn catalog_coverage(documents: &[AvailableDocument], scope_total: usize) -> CatalogCoverage {
211 let mut manual_sections = BTreeSet::new();
212 let mut markdown_sources = BTreeSet::new();
213 let mut personal_documents = false;
214 for document in documents {
215 match &document.origin {
216 AvailableDocumentOrigin::Documents => personal_documents = true,
217 AvailableDocumentOrigin::Source(source) => {
218 markdown_sources.insert(source.clone());
219 }
220 AvailableDocumentOrigin::ManualPath => {
221 if let Some(section) = &document.manual_section {
222 manual_sections.insert(section.clone());
223 }
224 }
225 }
226 }
227 CatalogCoverage {
228 scope_total: u32::try_from(scope_total).unwrap_or(u32::MAX),
229 manual_sections: manual_sections.into_iter().collect(),
230 markdown_sources: markdown_sources.into_iter().collect(),
231 personal_documents,
232 }
233}
234
235pub fn discover_documents(query: &CatalogQuery) -> Result<DocumentCatalog, String> {
242 let documents = list_available_documents().map_err(|error| error.to_string())?;
243 query_available_documents(&documents, query).map_err(|error| error.to_string())
244}
245
246fn validate_catalog_query(query: &CatalogQuery) -> Result<(), CatalogError> {
247 if query.pattern.as_deref().is_some_and(str::is_empty) {
248 return Err(CatalogError::EmptyPattern);
249 }
250 if query
251 .pattern
252 .as_ref()
253 .is_some_and(|pattern| pattern.len() > 4096)
254 {
255 return Err(CatalogError::PatternTooLong);
256 }
257 if query.limit == 0 || query.limit > 10_000 {
258 return Err(CatalogError::InvalidLimit);
259 }
260 if query.source.is_some() && query.manual_section.is_some() {
261 return Err(CatalogError::ConflictingSelectors);
262 }
263 if query.source.is_some() && query.kind == Some(CatalogDocumentKind::Manual)
264 || query.manual_section.is_some() && query.kind == Some(CatalogDocumentKind::Markdown)
265 {
266 return Err(CatalogError::ConflictingSelectors);
267 }
268 Ok(())
269}
270
271fn build_matcher(
272 pattern: &str,
273 syntax: SearchSyntax,
274 case: SearchCase,
275) -> Result<grep_regex::RegexMatcher, CatalogError> {
276 let mut builder = RegexMatcherBuilder::new();
277 builder.fixed_strings(syntax == SearchSyntax::Literal);
278 match case {
279 SearchCase::Insensitive => {
280 builder.case_insensitive(true);
281 }
282 SearchCase::Sensitive => {
283 builder.case_insensitive(false);
284 }
285 SearchCase::Smart => {
286 builder.case_smart(true);
287 }
288 }
289 builder
290 .build(pattern)
291 .map_err(|error| CatalogError::InvalidPattern(error.to_string()))
292}
293
294fn match_score(document: &AvailableDocument, query: &CatalogQuery) -> CatalogMatchScore {
295 if query.syntax != SearchSyntax::Literal {
296 return mant_protocol::catalog_literal_match_score("", None, query.case);
297 }
298 let Some(pattern) = query.pattern.as_deref() else {
299 return mant_protocol::catalog_literal_match_score("", None, query.case);
300 };
301 let catalog_path = available_catalog_path(document);
302 [
303 Some(document.name.as_str()),
304 Some(document.logical_path.as_str()),
305 pattern.contains('/').then_some(catalog_path.as_str()),
306 ]
307 .into_iter()
308 .flatten()
309 .map(|candidate| {
310 mant_protocol::catalog_literal_match_score(candidate, Some(pattern), query.case)
311 })
312 .min()
313 .unwrap_or_else(|| mant_protocol::catalog_literal_match_score("", None, query.case))
314}
315
316fn document_summary(document: &AvailableDocument) -> DocumentSummary {
317 let address = match &document.origin {
318 AvailableDocumentOrigin::Documents => DocumentAddress::Markdown {
319 path: document.logical_path.clone(),
320 origin: MarkdownOrigin::Documents,
321 },
322 AvailableDocumentOrigin::Source(source) => DocumentAddress::Markdown {
323 path: document.logical_path.clone(),
324 origin: MarkdownOrigin::Source {
325 name: source.clone(),
326 },
327 },
328 AvailableDocumentOrigin::ManualPath => DocumentAddress::Manual {
329 name: document.name.clone(),
330 manual_section: document.manual_section.clone().unwrap_or_default(),
331 },
332 };
333 DocumentSummary { address }
334}
335
336fn available_catalog_path(document: &AvailableDocument) -> String {
337 match &document.origin {
338 AvailableDocumentOrigin::Documents => format!("documents/{}", document.logical_path),
339 AvailableDocumentOrigin::Source(source) => {
340 format!("sources/{source}/{}", document.logical_path)
341 }
342 AvailableDocumentOrigin::ManualPath => format!(
343 "manual/{}/{}",
344 document.manual_section.as_deref().unwrap_or_default(),
345 document.name
346 ),
347 }
348}
349
350fn compare_precedence(left: &AvailableDocument, right: &AvailableDocument) -> std::cmp::Ordering {
351 fn class(document: &AvailableDocument) -> u8 {
352 match (&document.origin, document.source_priority) {
353 (AvailableDocumentOrigin::Documents, _) => 0,
354 (AvailableDocumentOrigin::Source(_), Some(priority))
355 if priority > BUILTIN_CONTENT_PRIORITY =>
356 {
357 1
358 }
359 (AvailableDocumentOrigin::ManualPath, _) => 2,
360 (AvailableDocumentOrigin::Source(_), _) => 3,
361 }
362 }
363
364 class(left)
365 .cmp(&class(right))
366 .then_with(|| match (&left.origin, &right.origin) {
367 (AvailableDocumentOrigin::Source(_), AvailableDocumentOrigin::Source(_)) => right
368 .source_priority
369 .unwrap_or_default()
370 .cmp(&left.source_priority.unwrap_or_default()),
371 _ => std::cmp::Ordering::Equal,
372 })
373}
374
375pub(crate) fn list_available_documents_from(
376 registered: Vec<RegisteredDocument>,
377 manuals: &[crate::ManualPage],
378) -> Vec<AvailableDocument> {
379 let mut documents = registered
380 .into_iter()
381 .map(|document| AvailableDocument {
382 name: document
383 .logical_path
384 .rsplit('/')
385 .next()
386 .unwrap_or(&document.logical_path)
387 .to_owned(),
388 logical_path: document.logical_path,
389 kind: AvailableDocumentKind::Markdown,
390 manual_section: None,
391 path: document.path,
392 source_priority: document.source_priority,
393 origin: match document.origin {
394 RegisteredDocumentOrigin::Documents => AvailableDocumentOrigin::Documents,
395 RegisteredDocumentOrigin::Source(source) => AvailableDocumentOrigin::Source(source),
396 },
397 })
398 .chain(manuals.iter().map(|page| AvailableDocument {
399 name: page.name.clone(),
400 logical_path: page.name.clone(),
401 kind: AvailableDocumentKind::Manual,
402 manual_section: Some(page.section.clone()),
403 path: page.path.clone(),
404 origin: AvailableDocumentOrigin::ManualPath,
405 source_priority: None,
406 }))
407 .collect::<Vec<_>>();
408 documents.sort_by(|left, right| {
409 left.logical_path
410 .cmp(&right.logical_path)
411 .then_with(|| compare_precedence(left, right))
412 .then_with(|| left.manual_section.cmp(&right.manual_section))
413 .then_with(|| left.origin.cmp(&right.origin))
414 });
415 documents
416}
417
418#[cfg(test)]
419mod tests {
420 use std::path::PathBuf;
421
422 use mant_sources::{RegisteredDocument, RegisteredDocumentOrigin};
423
424 use crate::ManualPage;
425
426 use mant_protocol::{
427 CatalogDocumentKind, CatalogQuery, DocumentAddress, SearchCase, SearchSyntax,
428 };
429
430 use super::{
431 AvailableDocument, AvailableDocumentKind, AvailableDocumentOrigin,
432 list_available_documents_from, query_available_documents,
433 };
434
435 #[test]
436 fn merges_both_namespaces_without_hiding_manual_sections() {
437 let documents = list_available_documents_from(
438 vec![RegisteredDocument {
439 logical_path: "printf".to_owned(),
440 path: PathBuf::from("/home/demo/.local/share/mant/documents/printf.md"),
441 origin: RegisteredDocumentOrigin::Documents,
442 source_priority: None,
443 }],
444 &[
445 ManualPage {
446 name: "printf".to_owned(),
447 section: "1".to_owned(),
448 path: PathBuf::from("/usr/share/man/man1/printf.1.gz"),
449 manual_root: PathBuf::from("/usr/share/man"),
450 },
451 ManualPage {
452 name: "printf".to_owned(),
453 section: "3".to_owned(),
454 path: PathBuf::from("/usr/share/man/man3/printf.3.gz"),
455 manual_root: PathBuf::from("/usr/share/man"),
456 },
457 ],
458 );
459
460 assert_eq!(documents.len(), 3);
461 assert_eq!(documents[0].kind, AvailableDocumentKind::Markdown);
462 assert_eq!(documents[0].origin, AvailableDocumentOrigin::Documents);
463 assert_eq!(documents[1].manual_section.as_deref(), Some("1"));
464 assert_eq!(documents[2].manual_section.as_deref(), Some("3"));
465 }
466
467 #[test]
468 fn keeps_shadowed_markdown_candidates_in_fallback_order() {
469 let documents = list_available_documents_from(
470 vec![
471 RegisteredDocument {
472 logical_path: "tool".to_owned(),
473 path: PathBuf::from("/data/mant/documents/tool.md"),
474 origin: RegisteredDocumentOrigin::Documents,
475 source_priority: None,
476 },
477 RegisteredDocument {
478 logical_path: "tool".to_owned(),
479 path: PathBuf::from("/data/mant/sources/alpha/tool.md"),
480 origin: RegisteredDocumentOrigin::Source("alpha".to_owned()),
481 source_priority: Some(1),
482 },
483 ],
484 &[],
485 );
486 assert_eq!(documents.len(), 2);
487 assert_eq!(documents[0].origin, AvailableDocumentOrigin::Documents);
488 assert_eq!(
489 documents[1].origin,
490 AvailableDocumentOrigin::Source("alpha".to_owned())
491 );
492 }
493
494 #[test]
495 fn catalog_orders_sources_around_the_native_manual_zero_baseline() {
496 let documents = list_available_documents_from(
497 vec![
498 RegisteredDocument {
499 logical_path: "tool".to_owned(),
500 path: PathBuf::from("/sources/low/tool.md"),
501 origin: RegisteredDocumentOrigin::Source("low".to_owned()),
502 source_priority: Some(-1),
503 },
504 RegisteredDocument {
505 logical_path: "tool".to_owned(),
506 path: PathBuf::from("/sources/high/tool.md"),
507 origin: RegisteredDocumentOrigin::Source("high".to_owned()),
508 source_priority: Some(1),
509 },
510 RegisteredDocument {
511 logical_path: "tool".to_owned(),
512 path: PathBuf::from("/sources/tie/tool.md"),
513 origin: RegisteredDocumentOrigin::Source("tie".to_owned()),
514 source_priority: Some(0),
515 },
516 ],
517 &[ManualPage {
518 name: "tool".to_owned(),
519 section: "1".to_owned(),
520 path: PathBuf::from("/man/tool.1"),
521 manual_root: PathBuf::from("/man"),
522 }],
523 );
524
525 assert_eq!(
526 documents
527 .iter()
528 .map(|document| match &document.origin {
529 AvailableDocumentOrigin::Source(name) => format!("source:{name}"),
530 AvailableDocumentOrigin::ManualPath => "manual".to_owned(),
531 AvailableDocumentOrigin::Documents => "documents".to_owned(),
532 })
533 .collect::<Vec<_>>(),
534 ["source:high", "manual", "source:tie", "source:low"]
535 );
536 }
537
538 #[test]
539 fn catalog_search_ranks_exact_prefix_and_substring_matches() {
540 let documents = ["process", "Start-Process", "process-tree"]
541 .into_iter()
542 .map(|name| AvailableDocument {
543 name: name.to_owned(),
544 logical_path: name.to_owned(),
545 kind: AvailableDocumentKind::Markdown,
546 manual_section: None,
547 path: PathBuf::from(format!("/data/{name}.md")),
548 origin: AvailableDocumentOrigin::Source("pwsh7".to_owned()),
549 source_priority: Some(1),
550 })
551 .collect::<Vec<_>>();
552 let catalog = query_available_documents(
553 &documents,
554 &CatalogQuery {
555 pattern: Some("process".to_owned()),
556 limit: 10,
557 ..CatalogQuery::default()
558 },
559 )
560 .expect("catalog");
561
562 assert_eq!(catalog.total, 3);
563 assert_eq!(catalog.documents[0].address.name(), "process");
564 assert_eq!(catalog.documents[1].address.name(), "process-tree");
565 assert_eq!(catalog.documents[2].address.name(), "Start-Process");
566 }
567
568 #[test]
569 fn catalog_prefers_case_faithful_prefixes_before_folded_prefixes() {
570 let documents = ["exec", "execlp", "EXECUTE", "execv", "execve"]
571 .into_iter()
572 .map(|name| AvailableDocument {
573 name: name.to_owned(),
574 logical_path: name.to_owned(),
575 kind: AvailableDocumentKind::Manual,
576 manual_section: Some("1".to_owned()),
577 path: PathBuf::from(format!("/man/{name}.1")),
578 origin: AvailableDocumentOrigin::ManualPath,
579 source_priority: None,
580 })
581 .collect::<Vec<_>>();
582 let catalog = query_available_documents(
583 &documents,
584 &CatalogQuery {
585 pattern: Some("exec".to_owned()),
586 ..CatalogQuery::default()
587 },
588 )
589 .expect("catalog");
590
591 assert_eq!(
592 catalog
593 .documents
594 .iter()
595 .map(|document| document.address.name())
596 .collect::<Vec<_>>(),
597 ["exec", "execlp", "execv", "execve", "EXECUTE"]
598 );
599 }
600
601 #[test]
602 fn catalog_distinguishes_unindexed_scopes_from_empty_name_matches() {
603 let documents = ["execve", "EPIOCGPARAMS"]
604 .into_iter()
605 .zip(["2", "2const"])
606 .map(|(name, section)| AvailableDocument {
607 name: name.to_owned(),
608 logical_path: name.to_owned(),
609 kind: AvailableDocumentKind::Manual,
610 manual_section: Some(section.to_owned()),
611 path: PathBuf::from(format!("/man/{name}.{section}")),
612 origin: AvailableDocumentOrigin::ManualPath,
613 source_priority: None,
614 })
615 .collect::<Vec<_>>();
616
617 let unindexed = query_available_documents(
618 &documents,
619 &CatalogQuery {
620 pattern: Some("exec".to_owned()),
621 kind: Some(CatalogDocumentKind::Manual),
622 manual_section: Some("42".to_owned()),
623 ..CatalogQuery::default()
624 },
625 )
626 .expect("unindexed scope remains a valid query");
627 assert_eq!(unindexed.total, 0);
628 assert_eq!(unindexed.coverage.scope_total, 0);
629 assert_eq!(unindexed.coverage.manual_sections, ["2", "2const"]);
630
631 let covered = query_available_documents(
632 &documents,
633 &CatalogQuery {
634 pattern: Some("not-present".to_owned()),
635 kind: Some(CatalogDocumentKind::Manual),
636 manual_section: Some("2".to_owned()),
637 ..CatalogQuery::default()
638 },
639 )
640 .expect("covered scope");
641 assert_eq!(covered.total, 0);
642 assert_eq!(covered.coverage.scope_total, 1);
643 }
644
645 #[test]
646 fn catalog_puts_an_exact_manual_before_every_prefix_and_substring() {
647 let documents = ["woman", "manpath", "man", "man.conf", "printf"]
648 .into_iter()
649 .map(|name| AvailableDocument {
650 name: name.to_owned(),
651 logical_path: name.to_owned(),
652 kind: AvailableDocumentKind::Manual,
653 manual_section: Some("1".to_owned()),
654 path: PathBuf::from(format!("/man/{name}.1")),
655 origin: AvailableDocumentOrigin::ManualPath,
656 source_priority: None,
657 })
658 .collect::<Vec<_>>();
659 let catalog = query_available_documents(
660 &documents,
661 &CatalogQuery {
662 pattern: Some("man".to_owned()),
663 limit: 10,
664 ..CatalogQuery::default()
665 },
666 )
667 .expect("catalog");
668 let names = catalog
669 .documents
670 .iter()
671 .map(|document| document.address.name())
672 .collect::<Vec<_>>();
673
674 assert_eq!(names, ["man", "man.conf", "manpath", "woman"]);
675 }
676
677 #[test]
678 fn catalog_ranks_hierarchical_exact_suffix_prefix_and_substring_matches() {
679 let documents = ["tool", "languages/en/tool", "toolbox", "guides/mytool"]
680 .into_iter()
681 .map(|logical_path| AvailableDocument {
682 name: logical_path.rsplit('/').next().expect("leaf").to_owned(),
683 logical_path: logical_path.to_owned(),
684 kind: AvailableDocumentKind::Markdown,
685 manual_section: None,
686 path: PathBuf::from(format!("/documents/{logical_path}.md")),
687 origin: AvailableDocumentOrigin::Documents,
688 source_priority: None,
689 })
690 .collect::<Vec<_>>();
691 let catalog = query_available_documents(
692 &documents,
693 &CatalogQuery {
694 pattern: Some("tool".to_owned()),
695 limit: 10,
696 ..CatalogQuery::default()
697 },
698 )
699 .expect("hierarchical catalog");
700 assert_eq!(
701 catalog
702 .documents
703 .iter()
704 .map(mant_protocol::DocumentSummary::catalog_path)
705 .collect::<Vec<_>>(),
706 [
707 "documents/languages/en/tool".to_owned(),
708 "documents/tool".to_owned(),
709 "documents/toolbox".to_owned(),
710 "documents/guides/mytool".to_owned(),
711 ]
712 );
713
714 let exact = AvailableDocument {
715 name: "tool".to_owned(),
716 logical_path: "en/tool".to_owned(),
717 kind: AvailableDocumentKind::Markdown,
718 manual_section: None,
719 path: PathBuf::from("/documents/en/tool.md"),
720 origin: AvailableDocumentOrigin::Documents,
721 source_priority: None,
722 };
723 let catalog = query_available_documents(
724 &[exact, documents[1].clone()],
725 &CatalogQuery {
726 pattern: Some("en/tool".to_owned()),
727 limit: 10,
728 ..CatalogQuery::default()
729 },
730 )
731 .expect("component suffix catalog");
732 assert_eq!(
733 catalog
734 .documents
735 .iter()
736 .map(mant_protocol::DocumentSummary::catalog_path)
737 .collect::<Vec<_>>(),
738 [
739 "documents/en/tool".to_owned(),
740 "documents/languages/en/tool".to_owned()
741 ]
742 );
743 }
744
745 #[test]
746 fn catalog_filters_keep_manual_sections_and_exact_addresses() {
747 let documents = vec![
748 AvailableDocument {
749 name: "printf".to_owned(),
750 logical_path: "printf".to_owned(),
751 kind: AvailableDocumentKind::Manual,
752 manual_section: Some("1".to_owned()),
753 path: PathBuf::from("/man/printf.1"),
754 origin: AvailableDocumentOrigin::ManualPath,
755 source_priority: None,
756 },
757 AvailableDocument {
758 name: "printf".to_owned(),
759 logical_path: "printf".to_owned(),
760 kind: AvailableDocumentKind::Manual,
761 manual_section: Some("3".to_owned()),
762 path: PathBuf::from("/man/printf.3"),
763 origin: AvailableDocumentOrigin::ManualPath,
764 source_priority: None,
765 },
766 ];
767 let catalog = query_available_documents(
768 &documents,
769 &CatalogQuery {
770 pattern: Some("^PRINT".to_owned()),
771 syntax: SearchSyntax::Regex,
772 case: SearchCase::Insensitive,
773 kind: Some(CatalogDocumentKind::Manual),
774 manual_section: Some("3".to_owned()),
775 limit: 10,
776 ..CatalogQuery::default()
777 },
778 )
779 .expect("catalog");
780
781 assert_eq!(catalog.documents.len(), 1);
782 assert_eq!(
783 catalog.documents[0].address,
784 DocumentAddress::Manual {
785 name: "printf".to_owned(),
786 manual_section: "3".to_owned(),
787 }
788 );
789 }
790}