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