Skip to main content

mant_engine/
catalog.rs

1//! Unifies registered Markdown and indexed manual pages for discovery clients.
2
3use 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/// Source family used to resolve one available document.
20#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
21pub enum AvailableDocumentKind {
22    /// Registered Markdown document.
23    Markdown,
24    /// Indexed native manual page.
25    Manual,
26}
27
28/// Precedence class and storage family for one available document.
29#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
30pub enum AvailableDocumentOrigin {
31    /// User-authored primary documents tree.
32    Documents,
33    /// One configured source cache, named by its configuration key.
34    Source(String),
35    /// A directory discovered through the native manual search path.
36    ManualPath,
37}
38
39/// One document discoverable by name through the ordinary query boundary.
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct AvailableDocument {
42    /// Short lookup name.
43    pub name: String,
44    /// Extension-free path relative to this document's origin.
45    pub logical_path: String,
46    /// Broad source format family.
47    pub kind: AvailableDocumentKind,
48    /// Native manual category, present only for manual pages.
49    pub manual_section: Option<String>,
50    /// Physical local source path.
51    pub path: PathBuf,
52    /// Storage namespace and precedence class.
53    pub origin: AvailableDocumentOrigin,
54    /// Configured priority relative to native manuals, or `None` otherwise.
55    pub source_priority: Option<i32>,
56}
57
58/// Invalid document-catalog filter or regular expression.
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub enum CatalogError {
61    /// An explicit pattern contained no text.
62    EmptyPattern,
63    /// A pattern exceeded the bounded request size.
64    PatternTooLong,
65    /// Pagination limit was zero or exceeded the protocol maximum.
66    InvalidLimit,
67    /// Source-family filters cannot describe any valid document.
68    ConflictingSelectors,
69    /// A regular expression could not be compiled.
70    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
93/// List every registered document candidate and locally indexed manual page.
94///
95/// # Errors
96///
97/// Returns an error when the platform data root or source configuration cannot
98/// be read or validated.
99pub 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
107/// Filter the unified local catalog using one shared CLI, TUI, and MCP policy.
108///
109/// # Errors
110///
111/// Returns a validation or regular-expression error without reading documents.
112pub 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::V0Dot8,
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
235/// Load and query the current local document catalog.
236///
237/// # Errors
238///
239/// Returns source configuration or catalog validation failures as text because
240/// both are operational boundaries for every frontend.
241pub 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}