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