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