wikioxide 0.1.0

MediaWiki wikitext parser producing typed domain models (templates, links, categories, warnings) with template expansion — no HTML, no network
Documentation
//! Error types.
//!
//! The parser itself is *total over input* — malformed wikitext always yields a document
//! plus warnings, never an error. `Result`s appear only around configuration loading,
//! parser construction and [`Title::parse`](crate::Title::parse).

/// Errors from loading or building a [`SiteConfig`](crate::SiteConfig).
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum ConfigError {
    /// The preset JSON could not be deserialized into the site-config schema.
    #[error("site-config JSON is invalid: {0}")]
    Json(String),
    /// Every configuration must define the main namespace (id 0).
    #[error("site config does not define the main namespace (id 0)")]
    MissingMainNamespace,
    /// Two namespace definitions share the same id.
    #[error("duplicate namespace id {0}")]
    DuplicateNamespace(i32),
    /// One namespace name or alias resolves to two different namespace ids.
    #[error("namespace alias `{alias}` maps to both namespace {first} and namespace {second}")]
    ConflictingNamespaceAlias {
        /// The offending alias, in folded (lower-case, space-collapsed) form.
        alias: String,
        /// Namespace id the alias was first registered for.
        first: i32,
        /// Conflicting namespace id the alias was registered for afterwards.
        second: i32,
    },
    /// The link-trail character class could not be parsed.
    #[error("link-trail pattern `{pattern}` is not a valid character class")]
    LinkTrail {
        /// The character-class body that failed to parse (e.g. `"a-z"`).
        pattern: String,
    },
}

/// Errors from constructing a parser.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum BuildError {
    /// A resource limit was set to a value that cannot work (e.g. zero).
    #[error("invalid limits: {0}")]
    InvalidLimits(&'static str),
    /// No [`SiteConfig`](crate::SiteConfig) was supplied and no preset default is
    /// available (the `presets` feature is disabled).
    #[error("no site config was supplied and the `presets` feature is disabled")]
    MissingSiteConfig,
}

/// Errors from [`Title::parse`](crate::Title::parse).
///
/// These mirror MediaWiki's title validity rules: the *parser* never surfaces them for
/// links found in wikitext (an invalid link target is simply not a link); they exist for
/// callers constructing titles from their own strings.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum TitleError {
    /// The title is empty after normalization (or only a namespace prefix).
    #[error("title is empty after normalization")]
    Empty,
    /// The title contains a character MediaWiki forbids in page names.
    #[error("title contains forbidden character `{0}`")]
    ForbiddenChar(char),
    /// The normalized title (dbkey) exceeds MediaWiki's 255-byte limit.
    #[error("title is {bytes} bytes long; MediaWiki allows at most 255")]
    TooLong {
        /// Length of the offending dbkey in bytes.
        bytes: usize,
    },
    /// The title is or contains a relative path component (`.` / `..`).
    #[error("title contains a relative path component")]
    RelativePath,
    /// The title contains a signature sequence (`~~~`), which MediaWiki rejects.
    #[error("title contains a signature sequence (`~~~`)")]
    Signature,
    /// A colon immediately follows the namespace prefix (e.g. `Template::X`).
    #[error("title has a colon directly after the namespace prefix")]
    LeadingColon,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn every_variant_renders_a_message() {
        let messages = [
            ConfigError::Json("boom".to_owned()).to_string(),
            ConfigError::MissingMainNamespace.to_string(),
            ConfigError::DuplicateNamespace(10).to_string(),
            ConfigError::ConflictingNamespaceAlias {
                alias: "шаблон".to_owned(),
                first: 10,
                second: 828,
            }
            .to_string(),
            ConfigError::LinkTrail {
                pattern: "a-".to_owned(),
            }
            .to_string(),
            BuildError::InvalidLimits("expansion depth must be non-zero").to_string(),
            BuildError::MissingSiteConfig.to_string(),
            TitleError::Empty.to_string(),
            TitleError::ForbiddenChar('<').to_string(),
            TitleError::TooLong { bytes: 300 }.to_string(),
            TitleError::RelativePath.to_string(),
            TitleError::Signature.to_string(),
            TitleError::LeadingColon.to_string(),
        ];
        for message in messages {
            assert!(!message.is_empty());
        }
    }

    #[test]
    fn errors_are_debuggable() {
        let rendered = format!(
            "{:?} {:?} {:?}",
            ConfigError::MissingMainNamespace,
            BuildError::InvalidLimits("x"),
            TitleError::Empty,
        );
        assert!(rendered.contains("MissingMainNamespace"));
    }
}