use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub use mant_ir::{DocumentAddress, MarkdownOrigin};
use crate::{SearchCase, SearchSyntax};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum CatalogSchema {
#[serde(rename = "mant.catalog/v0.9")]
V0Dot9,
}
impl CatalogSchema {
pub const ID: &'static str = "mant.catalog/v0.9";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum CatalogDocumentKind {
Markdown,
Manual,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CatalogQuery {
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(default)]
pub syntax: SearchSyntax,
#[serde(default)]
pub case: SearchCase,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<CatalogDocumentKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manual_section: Option<String>,
#[serde(default = "default_catalog_limit")]
#[schemars(range(min = 1, max = 10000))]
pub limit: u32,
#[serde(default)]
pub offset: u32,
}
impl Default for CatalogQuery {
fn default() -> Self {
Self {
pattern: None,
syntax: SearchSyntax::Literal,
case: SearchCase::Insensitive,
kind: None,
source: None,
manual_section: None,
limit: default_catalog_limit(),
offset: 0,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct DocumentSummary {
pub address: DocumentAddress,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct CatalogCoverage {
pub scope_total: u32,
pub manual_sections: Vec<String>,
pub markdown_sources: Vec<String>,
pub personal_documents: bool,
}
impl DocumentSummary {
#[must_use]
pub fn catalog_path(&self) -> String {
self.address.catalog_path()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(extend("$id" = "urn:mant:catalog:v0.9"))]
pub struct DocumentCatalog {
pub schema: CatalogSchema,
pub query: CatalogQuery,
pub coverage: CatalogCoverage,
pub total: u32,
pub returned: u32,
pub offset: u32,
pub truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_offset: Option<u32>,
pub documents: Vec<DocumentSummary>,
}
impl Default for DocumentCatalog {
fn default() -> Self {
Self {
schema: CatalogSchema::V0Dot9,
query: CatalogQuery::default(),
coverage: CatalogCoverage::default(),
total: 0,
returned: 0,
offset: 0,
truncated: false,
next_offset: None,
documents: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum CatalogMatchRank {
Exact,
ComponentSuffix,
Prefix,
Substring,
NoMatch,
Unranked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum CatalogSpellingRank {
Exact,
Folded,
Unranked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct CatalogMatchScore {
pub relevance: CatalogMatchRank,
pub spelling: CatalogSpellingRank,
}
#[must_use]
pub fn catalog_literal_match_rank(
name: &str,
pattern: Option<&str>,
case: SearchCase,
) -> CatalogMatchRank {
let Some(pattern) = pattern else {
return CatalogMatchRank::Unranked;
};
let insensitive = case == SearchCase::Insensitive
|| case == SearchCase::Smart && !pattern.chars().any(char::is_uppercase);
let (name, pattern) = if insensitive {
(name.to_lowercase(), pattern.to_lowercase())
} else {
(name.to_owned(), pattern.to_owned())
};
if name == pattern {
CatalogMatchRank::Exact
} else if name.ends_with(&format!("/{pattern}")) {
CatalogMatchRank::ComponentSuffix
} else if name.starts_with(&pattern) {
CatalogMatchRank::Prefix
} else if name.contains(&pattern) {
CatalogMatchRank::Substring
} else {
CatalogMatchRank::NoMatch
}
}
#[must_use]
pub fn catalog_literal_match_score(
name: &str,
pattern: Option<&str>,
case: SearchCase,
) -> CatalogMatchScore {
let relevance = catalog_literal_match_rank(name, pattern, case);
let Some(pattern) = pattern else {
return CatalogMatchScore {
relevance,
spelling: CatalogSpellingRank::Unranked,
};
};
let exact_relation = match relevance {
CatalogMatchRank::Exact => name == pattern,
CatalogMatchRank::ComponentSuffix => name.ends_with(&format!("/{pattern}")),
CatalogMatchRank::Prefix => name.starts_with(pattern),
CatalogMatchRank::Substring => name.contains(pattern),
CatalogMatchRank::NoMatch | CatalogMatchRank::Unranked => {
return CatalogMatchScore {
relevance,
spelling: CatalogSpellingRank::Unranked,
};
}
};
CatalogMatchScore {
relevance,
spelling: if exact_relation {
CatalogSpellingRank::Exact
} else {
CatalogSpellingRank::Folded
},
}
}
#[must_use]
pub const fn default_catalog_limit() -> u32 {
100
}
#[cfg(test)]
mod tests {
use super::{
CatalogMatchRank, CatalogSpellingRank, catalog_literal_match_rank,
catalog_literal_match_score,
};
use crate::SearchCase;
#[test]
fn literal_rank_distinguishes_substrings_from_non_matches() {
assert_eq!(
catalog_literal_match_rank("woman", Some("man"), SearchCase::Insensitive),
CatalogMatchRank::Substring
);
assert_eq!(
catalog_literal_match_rank("printf", Some("man"), SearchCase::Insensitive),
CatalogMatchRank::NoMatch
);
assert_eq!(
catalog_literal_match_rank("printf", None, SearchCase::Insensitive),
CatalogMatchRank::Unranked
);
}
#[test]
fn literal_score_prefers_case_faithful_prefixes_inside_one_tier() {
let lower = catalog_literal_match_score("execve", Some("exec"), SearchCase::Insensitive);
let folded = catalog_literal_match_score("EXECUTE", Some("exec"), SearchCase::Insensitive);
assert_eq!(lower.relevance, CatalogMatchRank::Prefix);
assert_eq!(folded.relevance, CatalogMatchRank::Prefix);
assert_eq!(lower.spelling, CatalogSpellingRank::Exact);
assert_eq!(folded.spelling, CatalogSpellingRank::Folded);
assert!(lower < folded);
}
}