Skip to main content

mant_protocol/
catalog.rs

1//! Versioned contracts for discovering locally available documents.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6pub use mant_ir::{DocumentAddress, MarkdownOrigin};
7
8use crate::{SearchCase, SearchSyntax};
9
10/// Exact schema marker for a local document catalog.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
12pub enum CatalogSchema {
13    /// Version 7 of the document-catalog protocol.
14    #[serde(rename = "mant.catalog/v7")]
15    V7,
16}
17
18/// Optional family filter for catalog discovery.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
20#[serde(rename_all = "kebab-case")]
21pub enum CatalogDocumentKind {
22    /// Registered Markdown documents.
23    Markdown,
24    /// Native manual pages.
25    Manual,
26}
27
28/// Bounded filtering and pagination shared by CLI, TUI, and MCP discovery.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
30#[serde(rename_all = "camelCase", deny_unknown_fields)]
31pub struct CatalogQuery {
32    /// Optional name or catalog-path pattern.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub pattern: Option<String>,
35    /// Pattern language used by [`Self::pattern`].
36    #[serde(default)]
37    pub syntax: SearchSyntax,
38    /// Case-matching policy.
39    #[serde(default)]
40    pub case: SearchCase,
41    /// Optional document-family restriction.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub kind: Option<CatalogDocumentKind>,
44    /// Optional configured Markdown source name.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub source: Option<String>,
47    /// Optional native manual category such as `1` or `3p`.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub manual_section: Option<String>,
50    /// Maximum rows returned after filtering.
51    #[serde(default = "default_catalog_limit")]
52    #[schemars(range(min = 1, max = 10000))]
53    pub limit: u32,
54    /// Number of matching rows skipped before collecting results.
55    #[serde(default)]
56    pub offset: u32,
57}
58
59impl Default for CatalogQuery {
60    fn default() -> Self {
61        Self {
62            pattern: None,
63            syntax: SearchSyntax::Literal,
64            case: SearchCase::Insensitive,
65            kind: None,
66            source: None,
67            manual_section: None,
68            limit: default_catalog_limit(),
69            offset: 0,
70        }
71    }
72}
73
74/// One catalog row identified entirely by logical names.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
76#[serde(rename_all = "camelCase")]
77pub struct DocumentSummary {
78    /// Stable logical document identity.
79    pub address: DocumentAddress,
80    /// Stable logical path used by tree and discovery frontends.
81    pub catalog_path: String,
82}
83
84/// Deterministically ordered page of discoverable local documents.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
86#[serde(rename_all = "camelCase")]
87#[schemars(extend("$id" = "urn:mant:catalog:v7"))]
88pub struct DocumentCatalog {
89    /// Exact response schema discriminator.
90    pub schema: CatalogSchema,
91    /// Total rows matching the filters before pagination.
92    pub total: u32,
93    /// Number of rows present in [`Self::documents`].
94    pub returned: u32,
95    /// Applied zero-based result offset.
96    pub offset: u32,
97    /// Whether matching rows remain after this page.
98    pub truncated: bool,
99    /// Offset for the next page, when one exists.
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub next_offset: Option<u32>,
102    /// Deterministically ordered page of document summaries.
103    pub documents: Vec<DocumentSummary>,
104}
105
106/// Stable relevance tier for literal catalog matching.
107///
108/// Frontends may add deterministic tie-breakers inside one tier, but must not
109/// place a prefix after a mere substring or an exact name after either.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
111pub enum CatalogMatchRank {
112    /// Complete name or path equality.
113    Exact,
114    /// Pattern equals the final slash-delimited path component.
115    ComponentSuffix,
116    /// Name or path begins with the pattern.
117    Prefix,
118    /// Pattern occurs elsewhere in the name or path.
119    Substring,
120    /// No literal pattern was supplied.
121    Unranked,
122}
123
124/// Rank one document name or slash-delimited path using the catalog's
125/// literal-search case policy.
126#[must_use]
127pub fn catalog_literal_match_rank(
128    name: &str,
129    pattern: Option<&str>,
130    case: SearchCase,
131) -> CatalogMatchRank {
132    let Some(pattern) = pattern else {
133        return CatalogMatchRank::Unranked;
134    };
135    let insensitive = case == SearchCase::Insensitive
136        || case == SearchCase::Smart && !pattern.chars().any(char::is_uppercase);
137    let (name, pattern) = if insensitive {
138        (name.to_lowercase(), pattern.to_lowercase())
139    } else {
140        (name.to_owned(), pattern.to_owned())
141    };
142    if name == pattern {
143        CatalogMatchRank::Exact
144    } else if name.ends_with(&format!("/{pattern}")) {
145        CatalogMatchRank::ComponentSuffix
146    } else if name.starts_with(&pattern) {
147        CatalogMatchRank::Prefix
148    } else {
149        CatalogMatchRank::Substring
150    }
151}
152
153#[must_use]
154/// Return the default maximum number of catalog rows.
155pub const fn default_catalog_limit() -> u32 {
156    100
157}