Skip to main content

mant_engine/
catalog.rs

1//! Unifies registered Markdown and indexed manual pages for discovery clients.
2
3use std::{error::Error, fmt, path::PathBuf};
4
5use grep_matcher::Matcher;
6use grep_regex::RegexMatcherBuilder;
7use mant_protocol::{
8    CatalogDocumentKind, CatalogMatchRank, CatalogQuery, CatalogSchema, DocumentAddress,
9    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 mut filtered = documents
123        .iter()
124        .filter(|document| {
125            query.kind.is_none_or(|kind| match kind {
126                CatalogDocumentKind::Markdown => document.kind == AvailableDocumentKind::Markdown,
127                CatalogDocumentKind::Manual => document.kind == AvailableDocumentKind::Manual,
128            }) && query.manual_section.as_ref().is_none_or(|section| {
129                document.manual_section.as_ref().is_some_and(|value| value == section)
130            }) && query.source.as_ref().is_none_or(|source| {
131                matches!(&document.origin, AvailableDocumentOrigin::Source(value) if value == source)
132            })
133        })
134        .filter_map(|document| {
135            let match_catalog_path = query
136                .pattern
137                .as_deref()
138                .is_some_and(|pattern| pattern.contains('/'));
139            let matched = compiled_pattern.as_ref().map_or(Ok(true), |matcher| {
140                matcher
141                    .is_match(document.name.as_bytes())
142                    .and_then(|matched| {
143                        if matched {
144                            Ok(true)
145                        } else {
146                            matcher.is_match(document.logical_path.as_bytes())
147                        }
148                    })
149                    .and_then(|matched| {
150                        if matched {
151                            Ok(true)
152                        } else if !match_catalog_path {
153                            Ok(false)
154                        } else {
155                            matcher.is_match(available_catalog_path(document).as_bytes())
156                        }
157                    })
158            });
159            matched.ok().filter(|matched| *matched).map(|_| document)
160        })
161        .collect::<Vec<_>>();
162    filtered.sort_by(|left, right| {
163        match_rank(left, query)
164            .cmp(&match_rank(right, query))
165            .then_with(|| {
166                left.logical_path
167                    .to_lowercase()
168                    .cmp(&right.logical_path.to_lowercase())
169            })
170            .then_with(|| left.logical_path.cmp(&right.logical_path))
171            .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
172            .then_with(|| left.name.cmp(&right.name))
173            .then_with(|| compare_precedence(left, right))
174            .then_with(|| left.manual_section.cmp(&right.manual_section))
175            .then_with(|| left.origin.cmp(&right.origin))
176    });
177
178    let total = filtered.len();
179    let offset = usize::try_from(query.offset)
180        .unwrap_or(usize::MAX)
181        .min(total);
182    let limit = usize::try_from(query.limit).unwrap_or(usize::MAX);
183    let end = offset.saturating_add(limit).min(total);
184    let documents = filtered[offset..end]
185        .iter()
186        .copied()
187        .map(document_summary)
188        .collect::<Vec<_>>();
189    Ok(DocumentCatalog {
190        schema: CatalogSchema::V7,
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
200/// Load and query the current local document catalog.
201///
202/// # Errors
203///
204/// Returns source configuration or catalog validation failures as text because
205/// both are operational boundaries for every frontend.
206pub fn discover_documents(query: &CatalogQuery) -> Result<DocumentCatalog, String> {
207    let documents = list_available_documents().map_err(|error| error.to_string())?;
208    query_available_documents(&documents, query).map_err(|error| error.to_string())
209}
210
211fn validate_catalog_query(query: &CatalogQuery) -> Result<(), CatalogError> {
212    if query.pattern.as_deref().is_some_and(str::is_empty) {
213        return Err(CatalogError::EmptyPattern);
214    }
215    if query
216        .pattern
217        .as_ref()
218        .is_some_and(|pattern| pattern.len() > 4096)
219    {
220        return Err(CatalogError::PatternTooLong);
221    }
222    if query.limit == 0 || query.limit > 10_000 {
223        return Err(CatalogError::InvalidLimit);
224    }
225    if query.source.is_some() && query.manual_section.is_some() {
226        return Err(CatalogError::ConflictingSelectors);
227    }
228    if query.source.is_some() && query.kind == Some(CatalogDocumentKind::Manual)
229        || query.manual_section.is_some() && query.kind == Some(CatalogDocumentKind::Markdown)
230    {
231        return Err(CatalogError::ConflictingSelectors);
232    }
233    Ok(())
234}
235
236fn build_matcher(
237    pattern: &str,
238    syntax: SearchSyntax,
239    case: SearchCase,
240) -> Result<grep_regex::RegexMatcher, CatalogError> {
241    let mut builder = RegexMatcherBuilder::new();
242    builder.fixed_strings(syntax == SearchSyntax::Literal);
243    match case {
244        SearchCase::Insensitive => {
245            builder.case_insensitive(true);
246        }
247        SearchCase::Sensitive => {
248            builder.case_insensitive(false);
249        }
250        SearchCase::Smart => {
251            builder.case_smart(true);
252        }
253    }
254    builder
255        .build(pattern)
256        .map_err(|error| CatalogError::InvalidPattern(error.to_string()))
257}
258
259fn match_rank(document: &AvailableDocument, query: &CatalogQuery) -> CatalogMatchRank {
260    if query.syntax != SearchSyntax::Literal {
261        return CatalogMatchRank::Unranked;
262    }
263    let Some(pattern) = query.pattern.as_deref() else {
264        return CatalogMatchRank::Unranked;
265    };
266    let catalog_path = available_catalog_path(document);
267    [
268        Some(document.name.as_str()),
269        Some(document.logical_path.as_str()),
270        pattern.contains('/').then_some(catalog_path.as_str()),
271    ]
272    .into_iter()
273    .flatten()
274    .map(|candidate| {
275        mant_protocol::catalog_literal_match_rank(candidate, Some(pattern), query.case)
276    })
277    .min()
278    .unwrap_or(CatalogMatchRank::Unranked)
279}
280
281fn document_summary(document: &AvailableDocument) -> DocumentSummary {
282    let address = match &document.origin {
283        AvailableDocumentOrigin::Documents => DocumentAddress::Markdown {
284            path: document.logical_path.clone(),
285            origin: MarkdownOrigin::Documents,
286        },
287        AvailableDocumentOrigin::Source(source) => DocumentAddress::Markdown {
288            path: document.logical_path.clone(),
289            origin: MarkdownOrigin::Source {
290                name: source.clone(),
291            },
292        },
293        AvailableDocumentOrigin::ManualPath => DocumentAddress::Manual {
294            name: document.name.clone(),
295            manual_section: document.manual_section.clone().unwrap_or_default(),
296        },
297    };
298    DocumentSummary {
299        catalog_path: address.catalog_path(),
300        address,
301    }
302}
303
304fn available_catalog_path(document: &AvailableDocument) -> String {
305    match &document.origin {
306        AvailableDocumentOrigin::Documents => format!("documents/{}", document.logical_path),
307        AvailableDocumentOrigin::Source(source) => {
308            format!("sources/{source}/{}", document.logical_path)
309        }
310        AvailableDocumentOrigin::ManualPath => format!(
311            "manual/{}/{}",
312            document.manual_section.as_deref().unwrap_or_default(),
313            document.name
314        ),
315    }
316}
317
318fn compare_precedence(left: &AvailableDocument, right: &AvailableDocument) -> std::cmp::Ordering {
319    fn class(document: &AvailableDocument) -> u8 {
320        match (&document.origin, document.source_priority) {
321            (AvailableDocumentOrigin::Documents, _) => 0,
322            (AvailableDocumentOrigin::Source(_), Some(priority))
323                if priority > BUILTIN_CONTENT_PRIORITY =>
324            {
325                1
326            }
327            (AvailableDocumentOrigin::ManualPath, _) => 2,
328            (AvailableDocumentOrigin::Source(_), _) => 3,
329        }
330    }
331
332    class(left)
333        .cmp(&class(right))
334        .then_with(|| match (&left.origin, &right.origin) {
335            (AvailableDocumentOrigin::Source(_), AvailableDocumentOrigin::Source(_)) => right
336                .source_priority
337                .unwrap_or_default()
338                .cmp(&left.source_priority.unwrap_or_default()),
339            _ => std::cmp::Ordering::Equal,
340        })
341}
342
343pub(crate) fn list_available_documents_from(
344    registered: Vec<RegisteredDocument>,
345    manuals: &[crate::ManualPage],
346) -> Vec<AvailableDocument> {
347    let mut documents = registered
348        .into_iter()
349        .map(|document| AvailableDocument {
350            name: document
351                .logical_path
352                .rsplit('/')
353                .next()
354                .unwrap_or(&document.logical_path)
355                .to_owned(),
356            logical_path: document.logical_path,
357            kind: AvailableDocumentKind::Markdown,
358            manual_section: None,
359            path: document.path,
360            source_priority: document.source_priority,
361            origin: match document.origin {
362                RegisteredDocumentOrigin::Documents => AvailableDocumentOrigin::Documents,
363                RegisteredDocumentOrigin::Source(source) => AvailableDocumentOrigin::Source(source),
364            },
365        })
366        .chain(manuals.iter().map(|page| AvailableDocument {
367            name: page.name.clone(),
368            logical_path: page.name.clone(),
369            kind: AvailableDocumentKind::Manual,
370            manual_section: Some(page.section.clone()),
371            path: page.path.clone(),
372            origin: AvailableDocumentOrigin::ManualPath,
373            source_priority: None,
374        }))
375        .collect::<Vec<_>>();
376    documents.sort_by(|left, right| {
377        left.logical_path
378            .cmp(&right.logical_path)
379            .then_with(|| compare_precedence(left, right))
380            .then_with(|| left.manual_section.cmp(&right.manual_section))
381            .then_with(|| left.origin.cmp(&right.origin))
382    });
383    documents
384}
385
386#[cfg(test)]
387mod tests {
388    use std::path::PathBuf;
389
390    use mant_sources::{RegisteredDocument, RegisteredDocumentOrigin};
391
392    use crate::ManualPage;
393
394    use mant_protocol::{
395        CatalogDocumentKind, CatalogQuery, DocumentAddress, SearchCase, SearchSyntax,
396    };
397
398    use super::{
399        AvailableDocument, AvailableDocumentKind, AvailableDocumentOrigin,
400        list_available_documents_from, query_available_documents,
401    };
402
403    #[test]
404    fn merges_both_namespaces_without_hiding_manual_sections() {
405        let documents = list_available_documents_from(
406            vec![RegisteredDocument {
407                logical_path: "printf".to_owned(),
408                path: PathBuf::from("/home/demo/.local/share/mant/documents/printf.md"),
409                origin: RegisteredDocumentOrigin::Documents,
410                source_priority: None,
411            }],
412            &[
413                ManualPage {
414                    name: "printf".to_owned(),
415                    section: "1".to_owned(),
416                    path: PathBuf::from("/usr/share/man/man1/printf.1.gz"),
417                    manual_root: PathBuf::from("/usr/share/man"),
418                },
419                ManualPage {
420                    name: "printf".to_owned(),
421                    section: "3".to_owned(),
422                    path: PathBuf::from("/usr/share/man/man3/printf.3.gz"),
423                    manual_root: PathBuf::from("/usr/share/man"),
424                },
425            ],
426        );
427
428        assert_eq!(documents.len(), 3);
429        assert_eq!(documents[0].kind, AvailableDocumentKind::Markdown);
430        assert_eq!(documents[0].origin, AvailableDocumentOrigin::Documents);
431        assert_eq!(documents[1].manual_section.as_deref(), Some("1"));
432        assert_eq!(documents[2].manual_section.as_deref(), Some("3"));
433    }
434
435    #[test]
436    fn keeps_shadowed_markdown_candidates_in_fallback_order() {
437        let documents = list_available_documents_from(
438            vec![
439                RegisteredDocument {
440                    logical_path: "tool".to_owned(),
441                    path: PathBuf::from("/data/mant/documents/tool.md"),
442                    origin: RegisteredDocumentOrigin::Documents,
443                    source_priority: None,
444                },
445                RegisteredDocument {
446                    logical_path: "tool".to_owned(),
447                    path: PathBuf::from("/data/mant/sources/alpha/tool.md"),
448                    origin: RegisteredDocumentOrigin::Source("alpha".to_owned()),
449                    source_priority: Some(1),
450                },
451            ],
452            &[],
453        );
454        assert_eq!(documents.len(), 2);
455        assert_eq!(documents[0].origin, AvailableDocumentOrigin::Documents);
456        assert_eq!(
457            documents[1].origin,
458            AvailableDocumentOrigin::Source("alpha".to_owned())
459        );
460    }
461
462    #[test]
463    fn catalog_orders_sources_around_the_native_manual_zero_baseline() {
464        let documents = list_available_documents_from(
465            vec![
466                RegisteredDocument {
467                    logical_path: "tool".to_owned(),
468                    path: PathBuf::from("/sources/low/tool.md"),
469                    origin: RegisteredDocumentOrigin::Source("low".to_owned()),
470                    source_priority: Some(-1),
471                },
472                RegisteredDocument {
473                    logical_path: "tool".to_owned(),
474                    path: PathBuf::from("/sources/high/tool.md"),
475                    origin: RegisteredDocumentOrigin::Source("high".to_owned()),
476                    source_priority: Some(1),
477                },
478                RegisteredDocument {
479                    logical_path: "tool".to_owned(),
480                    path: PathBuf::from("/sources/tie/tool.md"),
481                    origin: RegisteredDocumentOrigin::Source("tie".to_owned()),
482                    source_priority: Some(0),
483                },
484            ],
485            &[ManualPage {
486                name: "tool".to_owned(),
487                section: "1".to_owned(),
488                path: PathBuf::from("/man/tool.1"),
489                manual_root: PathBuf::from("/man"),
490            }],
491        );
492
493        assert_eq!(
494            documents
495                .iter()
496                .map(|document| match &document.origin {
497                    AvailableDocumentOrigin::Source(name) => format!("source:{name}"),
498                    AvailableDocumentOrigin::ManualPath => "manual".to_owned(),
499                    AvailableDocumentOrigin::Documents => "documents".to_owned(),
500                })
501                .collect::<Vec<_>>(),
502            ["source:high", "manual", "source:tie", "source:low"]
503        );
504    }
505
506    #[test]
507    fn catalog_search_ranks_exact_prefix_and_substring_matches() {
508        let documents = ["process", "Start-Process", "process-tree"]
509            .into_iter()
510            .map(|name| AvailableDocument {
511                name: name.to_owned(),
512                logical_path: name.to_owned(),
513                kind: AvailableDocumentKind::Markdown,
514                manual_section: None,
515                path: PathBuf::from(format!("/data/{name}.md")),
516                origin: AvailableDocumentOrigin::Source("pwsh7".to_owned()),
517                source_priority: Some(1),
518            })
519            .collect::<Vec<_>>();
520        let catalog = query_available_documents(
521            &documents,
522            &CatalogQuery {
523                pattern: Some("process".to_owned()),
524                limit: 10,
525                ..CatalogQuery::default()
526            },
527        )
528        .expect("catalog");
529
530        assert_eq!(catalog.total, 3);
531        assert_eq!(catalog.documents[0].address.name(), "process");
532        assert_eq!(catalog.documents[1].address.name(), "process-tree");
533        assert_eq!(catalog.documents[2].address.name(), "Start-Process");
534    }
535
536    #[test]
537    fn catalog_puts_an_exact_manual_before_every_prefix_and_substring() {
538        let documents = ["woman", "manpath", "man", "man.conf", "printf"]
539            .into_iter()
540            .map(|name| AvailableDocument {
541                name: name.to_owned(),
542                logical_path: name.to_owned(),
543                kind: AvailableDocumentKind::Manual,
544                manual_section: Some("1".to_owned()),
545                path: PathBuf::from(format!("/man/{name}.1")),
546                origin: AvailableDocumentOrigin::ManualPath,
547                source_priority: None,
548            })
549            .collect::<Vec<_>>();
550        let catalog = query_available_documents(
551            &documents,
552            &CatalogQuery {
553                pattern: Some("man".to_owned()),
554                limit: 10,
555                ..CatalogQuery::default()
556            },
557        )
558        .expect("catalog");
559        let names = catalog
560            .documents
561            .iter()
562            .map(|document| document.address.name())
563            .collect::<Vec<_>>();
564
565        assert_eq!(names, ["man", "man.conf", "manpath", "woman"]);
566    }
567
568    #[test]
569    fn catalog_ranks_hierarchical_exact_suffix_prefix_and_substring_matches() {
570        let documents = ["tool", "languages/en/tool", "toolbox", "guides/mytool"]
571            .into_iter()
572            .map(|logical_path| AvailableDocument {
573                name: logical_path.rsplit('/').next().expect("leaf").to_owned(),
574                logical_path: logical_path.to_owned(),
575                kind: AvailableDocumentKind::Markdown,
576                manual_section: None,
577                path: PathBuf::from(format!("/documents/{logical_path}.md")),
578                origin: AvailableDocumentOrigin::Documents,
579                source_priority: None,
580            })
581            .collect::<Vec<_>>();
582        let catalog = query_available_documents(
583            &documents,
584            &CatalogQuery {
585                pattern: Some("tool".to_owned()),
586                limit: 10,
587                ..CatalogQuery::default()
588            },
589        )
590        .expect("hierarchical catalog");
591        assert_eq!(
592            catalog
593                .documents
594                .iter()
595                .map(|document| document.catalog_path.as_str())
596                .collect::<Vec<_>>(),
597            [
598                "documents/languages/en/tool",
599                "documents/tool",
600                "documents/toolbox",
601                "documents/guides/mytool",
602            ]
603        );
604
605        let exact = AvailableDocument {
606            name: "tool".to_owned(),
607            logical_path: "en/tool".to_owned(),
608            kind: AvailableDocumentKind::Markdown,
609            manual_section: None,
610            path: PathBuf::from("/documents/en/tool.md"),
611            origin: AvailableDocumentOrigin::Documents,
612            source_priority: None,
613        };
614        let catalog = query_available_documents(
615            &[exact, documents[1].clone()],
616            &CatalogQuery {
617                pattern: Some("en/tool".to_owned()),
618                limit: 10,
619                ..CatalogQuery::default()
620            },
621        )
622        .expect("component suffix catalog");
623        assert_eq!(
624            catalog
625                .documents
626                .iter()
627                .map(|document| document.catalog_path.as_str())
628                .collect::<Vec<_>>(),
629            ["documents/en/tool", "documents/languages/en/tool"]
630        );
631    }
632
633    #[test]
634    fn catalog_filters_keep_manual_sections_and_exact_addresses() {
635        let documents = vec![
636            AvailableDocument {
637                name: "printf".to_owned(),
638                logical_path: "printf".to_owned(),
639                kind: AvailableDocumentKind::Manual,
640                manual_section: Some("1".to_owned()),
641                path: PathBuf::from("/man/printf.1"),
642                origin: AvailableDocumentOrigin::ManualPath,
643                source_priority: None,
644            },
645            AvailableDocument {
646                name: "printf".to_owned(),
647                logical_path: "printf".to_owned(),
648                kind: AvailableDocumentKind::Manual,
649                manual_section: Some("3".to_owned()),
650                path: PathBuf::from("/man/printf.3"),
651                origin: AvailableDocumentOrigin::ManualPath,
652                source_priority: None,
653            },
654        ];
655        let catalog = query_available_documents(
656            &documents,
657            &CatalogQuery {
658                pattern: Some("^PRINT".to_owned()),
659                syntax: SearchSyntax::Regex,
660                case: SearchCase::Insensitive,
661                kind: Some(CatalogDocumentKind::Manual),
662                manual_section: Some("3".to_owned()),
663                limit: 10,
664                ..CatalogQuery::default()
665            },
666        )
667        .expect("catalog");
668
669        assert_eq!(catalog.documents.len(), 1);
670        assert_eq!(
671            catalog.documents[0].address,
672            DocumentAddress::Manual {
673                name: "printf".to_owned(),
674                manual_section: "3".to_owned(),
675            }
676        );
677    }
678}