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/// Maximum Unicode scalar length of one native catalog pattern.
11pub const MAX_CATALOG_PATTERN_CHARS: usize = 4096;
12
13/// Exact schema marker for a local document catalog.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
15pub enum CatalogSchema {
16    /// Version 0.11 of the pre-stable document-catalog protocol.
17    #[serde(rename = "mant.catalog/v0.11")]
18    V0Dot11,
19}
20
21impl CatalogSchema {
22    /// Serialized identifier of the current catalog contract.
23    pub const ID: &'static str = "mant.catalog/v0.11";
24}
25
26/// Optional family filter for catalog discovery.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
28#[serde(rename_all = "kebab-case")]
29pub enum CatalogDocumentKind {
30    /// Registered Markdown documents.
31    Markdown,
32    /// Native manual pages.
33    Manual,
34}
35
36/// Bounded filtering and pagination shared by CLI, TUI, and MCP discovery.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
38#[serde(rename_all = "camelCase", deny_unknown_fields)]
39pub struct CatalogQuery {
40    /// Optional name or catalog-path pattern.
41    #[schemars(length(min = 1, max = MAX_CATALOG_PATTERN_CHARS))]
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub pattern: Option<String>,
44    /// Pattern language used by [`Self::pattern`].
45    #[serde(default)]
46    pub syntax: SearchSyntax,
47    /// Case-matching policy.
48    #[serde(default)]
49    pub case: SearchCase,
50    /// Optional document-family restriction.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub kind: Option<CatalogDocumentKind>,
53    /// Optional configured Markdown source name.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub source: Option<String>,
56    /// Optional native manual category such as `1` or `3p`.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub manual_section: Option<String>,
59    /// Maximum rows returned after filtering.
60    #[serde(default = "default_catalog_limit")]
61    #[schemars(range(min = 1, max = 10000))]
62    pub limit: u32,
63    /// Number of matching rows skipped before collecting results.
64    #[serde(default)]
65    pub offset: u32,
66}
67
68impl Default for CatalogQuery {
69    fn default() -> Self {
70        Self {
71            pattern: None,
72            syntax: SearchSyntax::Literal,
73            case: SearchCase::Insensitive,
74            kind: None,
75            source: None,
76            manual_section: None,
77            limit: default_catalog_limit(),
78            offset: 0,
79        }
80    }
81}
82
83/// One catalog row identified entirely by logical names.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
85#[serde(rename_all = "camelCase")]
86pub struct DocumentSummary {
87    /// Stable logical document identity.
88    pub address: DocumentAddress,
89}
90
91/// Indexed namespaces available to one catalog query.
92///
93/// `scope_total` is counted after applying the document-family, source, and
94/// manual-section selectors but before applying the name pattern. It lets a
95/// consumer distinguish an empty match set from an unindexed query scope.
96#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
97#[serde(rename_all = "camelCase")]
98pub struct CatalogCoverage {
99    /// Documents inside the selected scope before name matching.
100    pub scope_total: u32,
101    /// Exact native manual sections present anywhere in the local index.
102    pub manual_sections: Vec<String>,
103    /// Configured Markdown source names that currently contribute documents.
104    pub markdown_sources: Vec<String>,
105    /// Whether the personal documents tree currently contributes documents.
106    pub personal_documents: bool,
107}
108
109impl DocumentSummary {
110    /// Derive the stable logical path used by tree and discovery frontends.
111    #[must_use]
112    pub fn catalog_path(&self) -> String {
113        self.address.catalog_path()
114    }
115}
116
117/// Deterministically ordered page of discoverable local documents.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
119#[serde(rename_all = "camelCase")]
120#[schemars(extend("$id" = "urn:mant:catalog:v0.11"))]
121pub struct DocumentCatalog {
122    /// Exact response schema discriminator.
123    pub schema: CatalogSchema,
124    /// Normalized query used to construct this page.
125    pub query: CatalogQuery,
126    /// Coverage of the local catalog independently from the name pattern.
127    pub coverage: CatalogCoverage,
128    /// Total rows matching the filters before pagination.
129    pub total: u32,
130    /// Number of rows present in [`Self::documents`].
131    pub returned: u32,
132    /// Applied zero-based result offset.
133    pub offset: u32,
134    /// Whether matching rows remain after this page.
135    pub truncated: bool,
136    /// Offset for the next page, when one exists.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub next_offset: Option<u32>,
139    /// Deterministically ordered page of document summaries.
140    pub documents: Vec<DocumentSummary>,
141}
142
143impl Default for DocumentCatalog {
144    fn default() -> Self {
145        Self {
146            schema: CatalogSchema::V0Dot11,
147            query: CatalogQuery::default(),
148            coverage: CatalogCoverage::default(),
149            total: 0,
150            returned: 0,
151            offset: 0,
152            truncated: false,
153            next_offset: None,
154            documents: Vec::new(),
155        }
156    }
157}
158
159/// Stable relevance tier for literal catalog matching.
160///
161/// Frontends may add deterministic tie-breakers inside one tier, but must not
162/// place a prefix after a mere substring or an exact name after either.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
164pub enum CatalogMatchRank {
165    /// Complete name or path equality.
166    Exact,
167    /// Pattern equals the final slash-delimited path component.
168    ComponentSuffix,
169    /// Name or path begins with the pattern.
170    Prefix,
171    /// Pattern occurs elsewhere in the name or path.
172    Substring,
173    /// A literal pattern was supplied but does not occur in this candidate.
174    NoMatch,
175    /// No literal pattern was supplied.
176    Unranked,
177}
178
179/// Spelling fidelity inside one catalog relevance tier.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
181pub enum CatalogSpellingRank {
182    /// The candidate satisfies its relevance relation with the query's exact
183    /// spelling, including case.
184    Exact,
185    /// The relation holds only after case folding.
186    Folded,
187    /// No literal spelling comparison applies.
188    Unranked,
189}
190
191/// Complete literal relevance score shared by catalog frontends.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
193pub struct CatalogMatchScore {
194    /// Structural name/path relevance.
195    pub relevance: CatalogMatchRank,
196    /// Case fidelity within that relevance tier.
197    pub spelling: CatalogSpellingRank,
198}
199
200/// Rank one document name or slash-delimited path using the catalog's
201/// literal-search case policy.
202#[must_use]
203pub fn catalog_literal_match_rank(
204    name: &str,
205    pattern: Option<&str>,
206    case: SearchCase,
207) -> CatalogMatchRank {
208    let Some(pattern) = pattern else {
209        return CatalogMatchRank::Unranked;
210    };
211    let insensitive = case == SearchCase::Insensitive
212        || case == SearchCase::Smart && !pattern.chars().any(char::is_uppercase);
213    let (name, pattern) = if insensitive {
214        (name.to_lowercase(), pattern.to_lowercase())
215    } else {
216        (name.to_owned(), pattern.to_owned())
217    };
218    if name == pattern {
219        CatalogMatchRank::Exact
220    } else if name.ends_with(&format!("/{pattern}")) {
221        CatalogMatchRank::ComponentSuffix
222    } else if name.starts_with(&pattern) {
223        CatalogMatchRank::Prefix
224    } else if name.contains(&pattern) {
225        CatalogMatchRank::Substring
226    } else {
227        CatalogMatchRank::NoMatch
228    }
229}
230
231/// Rank one literal candidate while preferring case-faithful spellings inside
232/// the same exact, prefix, or substring tier.
233#[must_use]
234pub fn catalog_literal_match_score(
235    name: &str,
236    pattern: Option<&str>,
237    case: SearchCase,
238) -> CatalogMatchScore {
239    let relevance = catalog_literal_match_rank(name, pattern, case);
240    let Some(pattern) = pattern else {
241        return CatalogMatchScore {
242            relevance,
243            spelling: CatalogSpellingRank::Unranked,
244        };
245    };
246    let exact_relation = match relevance {
247        CatalogMatchRank::Exact => name == pattern,
248        CatalogMatchRank::ComponentSuffix => name.ends_with(&format!("/{pattern}")),
249        CatalogMatchRank::Prefix => name.starts_with(pattern),
250        CatalogMatchRank::Substring => name.contains(pattern),
251        CatalogMatchRank::NoMatch | CatalogMatchRank::Unranked => {
252            return CatalogMatchScore {
253                relevance,
254                spelling: CatalogSpellingRank::Unranked,
255            };
256        }
257    };
258    CatalogMatchScore {
259        relevance,
260        spelling: if exact_relation {
261            CatalogSpellingRank::Exact
262        } else {
263            CatalogSpellingRank::Folded
264        },
265    }
266}
267
268#[must_use]
269/// Return the default maximum number of catalog rows.
270pub const fn default_catalog_limit() -> u32 {
271    100
272}
273
274#[cfg(test)]
275mod tests {
276    use super::{
277        CatalogMatchRank, CatalogSpellingRank, catalog_literal_match_rank,
278        catalog_literal_match_score,
279    };
280    use crate::SearchCase;
281
282    #[test]
283    fn literal_rank_distinguishes_substrings_from_non_matches() {
284        assert_eq!(
285            catalog_literal_match_rank("woman", Some("man"), SearchCase::Insensitive),
286            CatalogMatchRank::Substring
287        );
288        assert_eq!(
289            catalog_literal_match_rank("printf", Some("man"), SearchCase::Insensitive),
290            CatalogMatchRank::NoMatch
291        );
292        assert_eq!(
293            catalog_literal_match_rank("printf", None, SearchCase::Insensitive),
294            CatalogMatchRank::Unranked
295        );
296    }
297
298    #[test]
299    fn literal_score_prefers_case_faithful_prefixes_inside_one_tier() {
300        let lower = catalog_literal_match_score("execve", Some("exec"), SearchCase::Insensitive);
301        let folded = catalog_literal_match_score("EXECUTE", Some("exec"), SearchCase::Insensitive);
302        assert_eq!(lower.relevance, CatalogMatchRank::Prefix);
303        assert_eq!(folded.relevance, CatalogMatchRank::Prefix);
304        assert_eq!(lower.spelling, CatalogSpellingRank::Exact);
305        assert_eq!(folded.spelling, CatalogSpellingRank::Folded);
306        assert!(lower < folded);
307    }
308}