use std::borrow::Cow;
use std::cmp::Ordering;
use std::fmt;
use std::hash::{Hash, Hasher};
use crate::config::SiteConfig;
use crate::error::TitleError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Namespace(i32);
impl Namespace {
pub const MAIN: Namespace = Namespace(0);
pub const TALK: Namespace = Namespace(1);
pub const USER: Namespace = Namespace(2);
pub const PROJECT: Namespace = Namespace(4);
pub const FILE: Namespace = Namespace(6);
pub const MEDIAWIKI: Namespace = Namespace(8);
pub const TEMPLATE: Namespace = Namespace(10);
pub const HELP: Namespace = Namespace(12);
pub const CATEGORY: Namespace = Namespace(14);
pub const MODULE: Namespace = Namespace(828);
pub const SPECIAL: Namespace = Namespace(-1);
pub const MEDIA: Namespace = Namespace(-2);
#[must_use]
pub const fn new(id: i32) -> Self {
Self(id)
}
#[must_use]
pub const fn id(self) -> i32 {
self.0
}
#[must_use]
pub const fn is_main(self) -> bool {
self.0 == 0
}
#[must_use]
pub const fn is_talk(self) -> bool {
self.0 > 0 && self.0 % 2 == 1
}
#[must_use]
pub const fn is_template(self) -> bool {
self.0 == 10
}
#[must_use]
pub const fn is_category(self) -> bool {
self.0 == 14
}
#[must_use]
pub const fn is_file(self) -> bool {
self.0 == 6
}
#[must_use]
pub const fn is_module(self) -> bool {
self.0 == 828
}
}
#[derive(Debug, Clone)]
pub struct Title {
namespace: Namespace,
dbkey: String,
ns_text: Box<str>,
}
impl Title {
pub fn parse(input: &str, config: &SiteConfig) -> Result<Title, TitleError> {
Self::parse_with_default(input, config, Namespace::MAIN)
}
pub(crate) fn synthetic_main(name: &str) -> Title {
Title {
namespace: Namespace::MAIN,
dbkey: name.replace(' ', "_"),
ns_text: Box::from(""),
}
}
pub(crate) fn parse_with_default(
input: &str,
config: &SiteConfig,
default_ns: Namespace,
) -> Result<Title, TitleError> {
let cleaned = clean_title_whitespace(input);
let mut rest = cleaned.as_str();
let mut default = default_ns;
if let Some(stripped) = rest.strip_prefix(':') {
rest = stripped.trim_start_matches(' ');
default = Namespace::MAIN;
}
if rest.is_empty() {
return Err(TitleError::Empty);
}
let (namespace, body) = match rest.split_once(':') {
Some((prefix, after)) => match config.resolve_namespace(prefix) {
Some(ns) => {
let after = after.trim_start_matches(' ');
if after.is_empty() {
return Err(TitleError::Empty);
}
(ns, after)
}
None => (default, rest),
},
None => (default, rest),
};
if body.starts_with(':') {
return Err(TitleError::LeadingColon);
}
for c in body.chars() {
if matches!(
c,
'#' | '<' | '>' | '[' | ']' | '|' | '{' | '}' | '\u{fffd}'
) || c.is_control()
{
return Err(TitleError::ForbiddenChar(c));
}
}
if body.contains("~~~") {
return Err(TitleError::Signature);
}
if is_relative_path(body) {
return Err(TitleError::RelativePath);
}
let cased: Cow<'_, str> = if config.ns_first_letter(namespace) {
ucfirst(body)
} else {
Cow::Borrowed(body)
};
let dbkey = cased.replace(' ', "_");
if dbkey.len() > 255 {
return Err(TitleError::TooLong { bytes: dbkey.len() });
}
let ns_text = config.namespace_name(namespace).unwrap_or_default();
Ok(Title {
namespace,
dbkey,
ns_text: ns_text.into(),
})
}
#[must_use]
pub const fn namespace(&self) -> Namespace {
self.namespace
}
#[must_use]
pub fn dbkey(&self) -> &str {
&self.dbkey
}
#[must_use]
pub fn text(&self) -> String {
let name = self.dbkey.replace('_', " ");
if self.ns_text.is_empty() {
name
} else {
format!("{}:{}", self.ns_text, name)
}
}
}
impl PartialEq for Title {
fn eq(&self, other: &Self) -> bool {
self.namespace == other.namespace && self.dbkey == other.dbkey
}
}
impl Eq for Title {}
impl Hash for Title {
fn hash<H: Hasher>(&self, state: &mut H) {
self.namespace.hash(state);
self.dbkey.hash(state);
}
}
impl PartialOrd for Title {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Title {
fn cmp(&self, other: &Self) -> Ordering {
self.namespace
.cmp(&other.namespace)
.then_with(|| self.dbkey.cmp(&other.dbkey))
}
}
impl fmt::Display for Title {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.text())
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for Title {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("Title", 3)?;
state.serialize_field("ns", &self.namespace.id())?;
state.serialize_field("dbkey", self.dbkey())?;
state.serialize_field("text", &self.text())?;
state.end()
}
}
fn clean_title_whitespace(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut pending_space = false;
for c in input.chars() {
match c {
'\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' => {}
'_'
| ' '
| '\u{00a0}'
| '\u{1680}'
| '\u{180e}'
| '\u{2000}'..='\u{200a}'
| '\u{2028}'
| '\u{2029}'
| '\u{202f}'
| '\u{205f}'
| '\u{3000}' => {
pending_space = true;
}
_ => {
if pending_space && !out.is_empty() {
out.push(' ');
}
pending_space = false;
out.push(c);
}
}
}
out
}
fn ucfirst(s: &str) -> Cow<'_, str> {
let Some(first) = s.chars().next() else {
return Cow::Borrowed(s);
};
if first.to_uppercase().eq(std::iter::once(first)) {
return Cow::Borrowed(s);
}
let mut out = String::with_capacity(s.len() + 3);
out.extend(first.to_uppercase());
out.push_str(&s[first.len_utf8()..]);
Cow::Owned(out)
}
fn is_relative_path(body: &str) -> bool {
body == "."
|| body == ".."
|| body.starts_with("./")
|| body.starts_with("../")
|| body.contains("/./")
|| body.contains("/../")
|| body.ends_with("/.")
|| body.ends_with("/..")
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use super::*;
use crate::config::{NamespaceDef, SiteConfig};
fn config() -> SiteConfig {
SiteConfig::builder("ru", "Тестовая вики")
.namespace(NamespaceDef::main())
.namespace(NamespaceDef::new(10, "Template", "Шаблон", ["Ш"], true))
.namespace(NamespaceDef::new(
14,
"Category",
"Категория",
Vec::<String>::new(),
true,
))
.namespace(NamespaceDef::new(2, "User", "Участник", ["U"], false))
.build()
.unwrap()
}
#[test]
fn synthetic_main_bypasses_validation() {
let title = Title::synthetic_main("API sandbox");
assert!(title.namespace().is_main());
assert_eq!(title.dbkey(), "API_sandbox");
assert_eq!(title.text(), "API sandbox");
}
#[cfg(feature = "serde")]
#[test]
fn serializes_namespace_dbkey_and_text() {
let title = Title::parse("Template:Пример", &config()).unwrap();
let json = serde_json::to_value(&title).unwrap();
assert_eq!(json["ns"], 10);
assert_eq!(json["dbkey"], "Пример");
assert_eq!(json["text"], "Шаблон:Пример");
}
#[test]
fn normalizes_whitespace_underscores_and_case() {
let title = Title::parse(" фёдор\u{00a0}__михайлович ", &config()).unwrap();
assert_eq!(title.dbkey(), "Фёдор_михайлович");
assert_eq!(title.text(), "Фёдор михайлович");
assert!(title.namespace().is_main());
}
#[test]
fn strips_bidi_marks() {
let title = Title::parse("\u{200e}test\u{200f}", &config()).unwrap();
assert_eq!(title.dbkey(), "Test");
}
#[test]
fn resolves_canonical_localized_and_alias_prefixes_case_insensitively() {
let cfg = config();
for input in ["Template:X", "шаблон:X", "ШАБЛОН:X", "ш:X", "template_:X"] {
let title = Title::parse(input, &cfg).unwrap();
assert_eq!(title.namespace(), Namespace::TEMPLATE, "{input}");
assert_eq!(title.text(), "Шаблон:X", "{input}");
}
}
#[test]
fn unknown_prefix_stays_in_main_namespace_with_colon() {
let title = Title::parse("foo: bar", &config()).unwrap();
assert!(title.namespace().is_main());
assert_eq!(title.dbkey(), "Foo:_bar");
assert_eq!(title.text(), "Foo: bar");
}
#[test]
fn leading_colon_is_stripped_and_still_resolves_namespaces() {
let cfg = config();
let title = Title::parse(":Категория:Люди", &cfg).unwrap();
assert_eq!(title.namespace(), Namespace::CATEGORY);
assert_eq!(title.text(), "Категория:Люди");
let main = Title::parse(": просто страница", &cfg).unwrap();
assert!(main.namespace().is_main());
}
#[test]
fn default_namespace_applies_only_without_prefix() {
let cfg = config();
let tpl = Title::parse_with_default("навигация", &cfg, Namespace::TEMPLATE).unwrap();
assert_eq!(tpl.text(), "Шаблон:Навигация");
let forced_main =
Title::parse_with_default(":навигация", &cfg, Namespace::TEMPLATE).unwrap();
assert!(forced_main.namespace().is_main());
let cat = Title::parse_with_default("Категория:X", &cfg, Namespace::TEMPLATE).unwrap();
assert_eq!(cat.namespace(), Namespace::CATEGORY);
}
#[test]
fn case_sensitive_namespace_keeps_lowercase_first_letter() {
let title = Title::parse("User:lowercase", &config()).unwrap();
assert_eq!(title.dbkey(), "lowercase");
assert_eq!(title.text(), "Участник:lowercase");
}
#[test]
fn ucfirst_expands_multichar_uppercase() {
let title = Title::parse("ß-test", &config()).unwrap();
assert_eq!(title.dbkey(), "SS-test");
}
#[test]
fn ucfirst_helper_handles_empty_and_uppercase_input_without_allocating() {
assert_eq!(ucfirst(""), "");
assert!(matches!(ucfirst("Already"), Cow::Borrowed(_)));
assert!(matches!(ucfirst("ёж"), Cow::Owned(_)));
}
#[test]
fn empty_forms_are_rejected() {
let cfg = config();
for input in ["", " ", "_", ":", " : ", "Шаблон:", "Шаблон: "] {
assert_eq!(
Title::parse(input, &cfg),
Err(TitleError::Empty),
"{input:?}"
);
}
}
#[test]
fn forbidden_characters_are_rejected() {
let cfg = config();
for (input, bad) in [
("a<b", '<'),
("a>b", '>'),
("a[b", '['),
("a]b", ']'),
("a|b", '|'),
("a{b", '{'),
("a}b", '}'),
("a#b", '#'),
("a\u{fffd}b", '\u{fffd}'),
("a\tb", '\t'),
] {
assert_eq!(
Title::parse(input, &cfg),
Err(TitleError::ForbiddenChar(bad)),
"{input:?}"
);
}
}
#[test]
fn signatures_relative_paths_and_double_colons_are_rejected() {
let cfg = config();
assert_eq!(Title::parse("a~~~b", &cfg), Err(TitleError::Signature));
assert_eq!(Title::parse("../up", &cfg), Err(TitleError::RelativePath));
assert_eq!(Title::parse("a/./b", &cfg), Err(TitleError::RelativePath));
assert_eq!(Title::parse("a/..", &cfg), Err(TitleError::RelativePath));
assert_eq!(
Title::parse("Шаблон::X", &cfg),
Err(TitleError::LeadingColon)
);
assert_eq!(Title::parse("::X", &cfg), Err(TitleError::LeadingColon));
}
#[test]
fn overlong_titles_are_rejected() {
let cfg = config();
let long = "x".repeat(256);
assert_eq!(
Title::parse(&long, &cfg),
Err(TitleError::TooLong { bytes: 256 })
);
assert!(Title::parse(&"x".repeat(255), &cfg).is_ok());
}
#[test]
fn equality_ignores_the_prefix_spelling() {
let cfg = config();
let via_alias = Title::parse("ш:Foo", &cfg).unwrap();
let via_canonical = Title::parse("Template:Foo", &cfg).unwrap();
assert_eq!(via_alias, via_canonical);
let mut set = HashSet::new();
set.insert(via_alias);
assert!(set.contains(&via_canonical));
}
#[test]
fn ordering_is_by_namespace_then_dbkey() {
let cfg = config();
let mut titles = [
Title::parse("Шаблон:B", &cfg).unwrap(),
Title::parse("A", &cfg).unwrap(),
Title::parse("Шаблон:A", &cfg).unwrap(),
];
titles.sort();
let rendered: Vec<String> = titles.iter().map(Title::text).collect();
assert_eq!(rendered, ["A", "Шаблон:A", "Шаблон:B"]);
assert!(titles[0] < titles[1]);
}
#[test]
fn titles_are_cloneable() {
let title = Title::parse("Шаблон:X", &config()).unwrap();
let clone = title.clone();
assert_eq!(title, clone);
assert_eq!(clone.text(), "Шаблон:X");
}
#[test]
fn display_matches_text() {
let title = Title::parse("шаблон:не переведено 3", &config()).unwrap();
assert_eq!(title.to_string(), "Шаблон:Не переведено 3");
assert!(format!("{title:?}").contains("Title"));
}
#[test]
fn namespace_constants_and_predicates() {
assert_eq!(Namespace::MAIN.id(), 0);
assert_eq!(Namespace::TEMPLATE.id(), 10);
assert_eq!(Namespace::MODULE.id(), 828);
assert_eq!(Namespace::SPECIAL.id(), -1);
assert_eq!(Namespace::MEDIA.id(), -2);
assert_eq!(Namespace::new(4), Namespace::PROJECT);
assert!(Namespace::TALK.is_talk());
assert!(!Namespace::USER.is_talk());
assert!(!Namespace::SPECIAL.is_talk());
assert!(Namespace::FILE.is_file());
assert!(Namespace::CATEGORY.is_category());
assert!(Namespace::MODULE.is_module());
assert!(!Namespace::MAIN.is_template());
assert!(Namespace::MEDIAWIKI.id() == 8 && Namespace::HELP.id() == 12);
assert!(Namespace::TALK < Namespace::USER);
}
}