1use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6pub use mant_ir::{DocumentAddress, MarkdownOrigin};
7
8use crate::{SearchCase, SearchSyntax};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
12pub enum CatalogSchema {
13 #[serde(rename = "mant.catalog/v0.9")]
15 V0Dot9,
16}
17
18impl CatalogSchema {
19 pub const ID: &'static str = "mant.catalog/v0.9";
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
25#[serde(rename_all = "kebab-case")]
26pub enum CatalogDocumentKind {
27 Markdown,
29 Manual,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
35#[serde(rename_all = "camelCase", deny_unknown_fields)]
36pub struct CatalogQuery {
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub pattern: Option<String>,
40 #[serde(default)]
42 pub syntax: SearchSyntax,
43 #[serde(default)]
45 pub case: SearchCase,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub kind: Option<CatalogDocumentKind>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub source: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub manual_section: Option<String>,
55 #[serde(default = "default_catalog_limit")]
57 #[schemars(range(min = 1, max = 10000))]
58 pub limit: u32,
59 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
81#[serde(rename_all = "camelCase")]
82pub struct DocumentSummary {
83 pub address: DocumentAddress,
85}
86
87#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
93#[serde(rename_all = "camelCase")]
94pub struct CatalogCoverage {
95 pub scope_total: u32,
97 pub manual_sections: Vec<String>,
99 pub markdown_sources: Vec<String>,
101 pub personal_documents: bool,
103}
104
105impl DocumentSummary {
106 #[must_use]
108 pub fn catalog_path(&self) -> String {
109 self.address.catalog_path()
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
115#[serde(rename_all = "camelCase")]
116#[schemars(extend("$id" = "urn:mant:catalog:v0.9"))]
117pub struct DocumentCatalog {
118 pub schema: CatalogSchema,
120 pub query: CatalogQuery,
122 pub coverage: CatalogCoverage,
124 pub total: u32,
126 pub returned: u32,
128 pub offset: u32,
130 pub truncated: bool,
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub next_offset: Option<u32>,
135 pub documents: Vec<DocumentSummary>,
137}
138
139impl Default for DocumentCatalog {
140 fn default() -> Self {
141 Self {
142 schema: CatalogSchema::V0Dot9,
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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
160pub enum CatalogMatchRank {
161 Exact,
163 ComponentSuffix,
165 Prefix,
167 Substring,
169 NoMatch,
171 Unranked,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
177pub enum CatalogSpellingRank {
178 Exact,
181 Folded,
183 Unranked,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
189pub struct CatalogMatchScore {
190 pub relevance: CatalogMatchRank,
192 pub spelling: CatalogSpellingRank,
194}
195
196#[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#[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]
265pub 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}