1use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6pub use mant_ir::{DocumentAddress, MarkdownOrigin};
7
8use crate::{SearchCase, SearchSyntax};
9
10pub const MAX_CATALOG_PATTERN_CHARS: usize = 4096;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
15pub enum CatalogSchema {
16 #[serde(rename = "mant.catalog/v0.11")]
18 V0Dot11,
19}
20
21impl CatalogSchema {
22 pub const ID: &'static str = "mant.catalog/v0.11";
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
28#[serde(rename_all = "kebab-case")]
29pub enum CatalogDocumentKind {
30 Markdown,
32 Manual,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
38#[serde(rename_all = "camelCase", deny_unknown_fields)]
39pub struct CatalogQuery {
40 #[schemars(length(min = 1, max = MAX_CATALOG_PATTERN_CHARS))]
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub pattern: Option<String>,
44 #[serde(default)]
46 pub syntax: SearchSyntax,
47 #[serde(default)]
49 pub case: SearchCase,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub kind: Option<CatalogDocumentKind>,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub source: Option<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub manual_section: Option<String>,
59 #[serde(default = "default_catalog_limit")]
61 #[schemars(range(min = 1, max = 10000))]
62 pub limit: u32,
63 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
85#[serde(rename_all = "camelCase")]
86pub struct DocumentSummary {
87 pub address: DocumentAddress,
89}
90
91#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
97#[serde(rename_all = "camelCase")]
98pub struct CatalogCoverage {
99 pub scope_total: u32,
101 pub manual_sections: Vec<String>,
103 pub markdown_sources: Vec<String>,
105 pub personal_documents: bool,
107}
108
109impl DocumentSummary {
110 #[must_use]
112 pub fn catalog_path(&self) -> String {
113 self.address.catalog_path()
114 }
115}
116
117#[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 pub schema: CatalogSchema,
124 pub query: CatalogQuery,
126 pub coverage: CatalogCoverage,
128 pub total: u32,
130 pub returned: u32,
132 pub offset: u32,
134 pub truncated: bool,
136 #[serde(skip_serializing_if = "Option::is_none")]
138 pub next_offset: Option<u32>,
139 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
164pub enum CatalogMatchRank {
165 Exact,
167 ComponentSuffix,
169 Prefix,
171 Substring,
173 NoMatch,
175 Unranked,
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
181pub enum CatalogSpellingRank {
182 Exact,
185 Folded,
187 Unranked,
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
193pub struct CatalogMatchScore {
194 pub relevance: CatalogMatchRank,
196 pub spelling: CatalogSpellingRank,
198}
199
200#[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#[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]
269pub 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}