mwtitle 0.2.7

MediaWiki title validation and formatting
Documentation
/*
Copyright (C) 2021 Kunal Mehta <legoktm@debian.org>

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

//! A type to represent a [siteinfo](https://www.mediawiki.org/wiki/API:Siteinfo) response.

use serde::{de, Deserialize};
use std::collections::HashMap;

use crate::{Error, Result};

// TODO: Port all this to mwapi_responses

/// Represents a [siteinfo response](https://www.mediawiki.org/wiki/API:Siteinfo)
/// suitable for making a [`TitleCodec`](crate::TitleCodec) or a [`NamespaceMap`](crate::NamespaceMap).
#[derive(Debug, Deserialize)]
pub struct Response {
    pub query: SiteInfo,
}

/// Represents the `query` field of a siteinfo response.
///
/// Can be deserialized from `formatversion=1` or `formatversion=2` of siteinfo,
/// as long as the response contains  `general`, `namespaces`, `namespacealiases`,
/// and optionally `interwikimap`.
/// `interwikimap` is required when the `SiteInfo` is passed
/// to [`TitleCodec::from_site_info`](crate::TitleCodec::from_site_info) to create
/// a [`TitleCodec`](crate::TitleCodec) that parses interwikis,
/// but is not required for [`NamespaceMap::from_site_info`](crate::NamespaceMap::from_site_info).
#[derive(Clone, Debug, Deserialize)]
pub struct SiteInfo {
    pub general: General,
    pub namespaces: HashMap<String, NamespaceInfo>,
    #[serde(rename = "namespacealiases")]
    pub namespace_aliases: Vec<NamespaceAlias>,
    #[serde(default)]
    #[serde(rename = "interwikimap")]
    pub interwiki_map: Vec<Interwiki>,
}

/// Represents the `general` field of a [`SiteInfo`].
///
/// Contains only the fields required for [`TitleCodec`](crate::TitleCodec).
#[derive(Clone, Debug, Deserialize)]
pub struct General {
    #[serde(rename = "mainpage")]
    pub main_page: String,
    pub lang: String,
    #[serde(rename = "legaltitlechars")]
    pub legal_title_chars: String,
}

/// Represents a namespace object in the `namespaces` field of a [`SiteInfo`].
///
/// Contains only the fields required to generate a [`NamespaceMap`](crate::NamespaceMap) that can be used
/// by a [`TitleCodec`](crate::TitleCodec) to parse the namespace in a [`Title`](crate::Title).
/// Supports `formatversion=1` with the local namespace name in the `"*"` field,
/// and `formatversion=2` with the local namespace name in the `"name"` field.
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct NamespaceInfo {
    pub id: i32,
    pub case: String,
    #[serde(alias = "*")]
    pub name: String,
    pub canonical: Option<String>,
}

impl NamespaceInfo {
    /// Fallibly convert a `HashMap<String, String>` and a `String` into a `NamespaceInfo`.
    ///
    /// # Errors
    /// Fails if any of the keys `"id"`, `"case"` is missing,
    /// or if the value for `"id"` cannot be parsed as an `i32`.
    pub fn try_from_iter<I: IntoIterator<Item = (String, String)>>(
        iter: I,
    ) -> Result<Self> {
        use Error::*;
        let mut items: Vec<_> = iter
            .into_iter()
            .filter(|(k, _)| {
                ["id", "case", "name", "canonical"].contains(&k.as_str())
            })
            .collect();
        let mut get_string = move |key_to_find| {
            items
                .iter()
                .position(|(k, _)| k == key_to_find)
                .map(|pos| items.remove(pos).1)
                .ok_or(NamespaceInfoMissingKey(key_to_find))
        };
        let id = get_string("id")?;
        let id = id.parse().map_err(|_| NamespaceInfoInvalidId(id))?;
        Ok(NamespaceInfo {
            id,
            case: get_string("case")?,
            name: get_string("name")?,
            canonical: get_string("canonical").ok(),
        })
    }
}

#[test]
fn test_namespace_info_from_iter() {
    let (input, expected) = (
        [("id", "0"), ("case", "first-letter"), ("name", "")],
        NamespaceInfo {
            id: 0,
            case: "first-letter".into(),
            name: "".into(),
            canonical: None,
        },
    );
    assert_eq!(
        NamespaceInfo::try_from_iter(
            input
                .into_iter()
                .map(|(k, v)| (k.to_string(), v.to_string()))
        )
        .map_err(|e| e.to_string()),
        Ok(expected)
    );
}

/// Represents a namespace alias object in the `namespacealiases` field of a [`SiteInfo`].
///
/// Supports `formatversion=1` with the alias in the `"*"` field,
/// and `formatversion=2` with the alias in the `"alias"` field.
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct NamespaceAlias {
    pub id: i32,
    #[serde(alias = "*")]
    pub alias: String,
}

/// Represents an interwiki object in the `interwikimap` field of a [`SiteInfo`].
///
/// Contains only the fields required to generate two [`InterwikiSet`s](crate::InterwikiSet) that can be used
/// by a [`TitleCodec`](crate::TitleCodec) to parse the interwikis in a [`Title`](crate::Title).
/// Supports `formatversion=1` where the `localinterwiki` field is `""` for local interwikis,
/// and `formatversion=2` where it is `true`.
#[derive(Clone, Debug, Deserialize)]
pub struct Interwiki {
    pub prefix: String,
    #[serde(default)]
    #[serde(rename = "localinterwiki")]
    #[serde(deserialize_with = "deserialize_bool_or_string")]
    pub local_interwiki: bool,
}

/// Enum to represent booleans in both `formatversion=1` (present empty string
/// for true) and `formatversion=2` (real booleans).
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
enum BooleanCompat {
    V2(bool),
    // allow dead code because serde needs the type to pick the right format
    #[allow(dead_code)]
    V1(String),
}

fn deserialize_bool_or_string<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
    D: de::Deserializer<'de>,
{
    let val = BooleanCompat::deserialize(deserializer)?;
    Ok(match val {
        BooleanCompat::V2(bool) => bool,
        // Merely the string being present is true. If absent, bool::default()
        // would be invoked for false.
        BooleanCompat::V1(_) => true,
    })
}