wikioxide 0.1.0

MediaWiki wikitext parser producing typed domain models (templates, links, categories, warnings) with template expansion — no HTML, no network
Documentation
//! Template and existence seams (dependency inversion): the expansion engine performs
//! no I/O — it asks a [`TemplateSource`] for template/module wikitext and, optionally, an
//! [`ExistenceProvider`] whether a page exists. Callers back these with a cache, a wiki
//! dump, or a live API behind their own async layer.

use std::collections::BTreeMap;
use std::sync::{Arc, RwLock};

use crate::title::Title;

/// The result of asking a [`TemplateSource`] for a page.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TemplateFetch {
    /// The page exists; here is its raw wikitext.
    Found(Arc<str>),
    /// The page is known not to exist (a verified red link).
    Missing,
    /// The source does not have the page cached yet. Under
    /// [`UnresolvedPolicy::Halt`](crate::UnresolvedPolicy::Halt) this pauses expansion so
    /// the caller can fetch it; under `RedLink` it is treated like [`Missing`].
    ///
    /// [`Missing`]: TemplateFetch::Missing
    Unknown,
}

/// Where template and module wikitext comes from. Implementations must be pure and
/// deterministic per call (the engine may ask for the same title more than once) and
/// perform no blocking I/O on the calling thread — prefetch into a cache instead.
pub trait TemplateSource: Send + Sync {
    /// Returns the wikitext of `title`, or why it is unavailable.
    fn fetch(&self, title: &Title) -> TemplateFetch;
}

/// A [`TemplateSource`] that knows nothing — every lookup is [`TemplateFetch::Unknown`].
/// The default source when a parser is built without one.
#[derive(Debug, Clone, Copy, Default)]
pub struct EmptyTemplateSource;

impl TemplateSource for EmptyTemplateSource {
    fn fetch(&self, _title: &Title) -> TemplateFetch {
        TemplateFetch::Unknown
    }
}

/// A thread-safe in-memory template cache. Share one via [`Arc`] between the parser and a
/// prefetch task: expand, collect [`MissingTemplates`](crate::MissingTemplates), fill the
/// store, expand again.
#[derive(Debug, Default)]
pub struct InMemoryTemplateStore {
    pages: RwLock<BTreeMap<Title, Option<Arc<str>>>>,
}

impl InMemoryTemplateStore {
    /// An empty store.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Records that `title` exists with the given wikitext.
    pub fn insert(&self, title: Title, wikitext: impl Into<Arc<str>>) {
        self.write().insert(title, Some(wikitext.into()));
    }

    /// Records that `title` is verified not to exist (a red link).
    pub fn insert_missing(&self, title: Title) {
        self.write().insert(title, None);
    }

    /// Bulk-loads pages; `None` marks a verified-missing page.
    pub fn extend(&self, pages: impl IntoIterator<Item = (Title, Option<Arc<str>>)>) {
        self.write().extend(pages);
    }

    /// Number of cached entries (present and verified-missing).
    #[must_use]
    pub fn len(&self) -> usize {
        self.read().len()
    }

    /// Whether the store holds no entries.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.read().is_empty()
    }

    #[allow(
        clippy::expect_used,
        reason = "the lock is only poisoned if a holder panicked; propagating that panic is correct"
    )]
    fn read(&self) -> std::sync::RwLockReadGuard<'_, BTreeMap<Title, Option<Arc<str>>>> {
        self.pages.read().expect("template store lock poisoned")
    }

    #[allow(
        clippy::expect_used,
        reason = "the lock is only poisoned if a holder panicked; propagating that panic is correct"
    )]
    fn write(&self) -> std::sync::RwLockWriteGuard<'_, BTreeMap<Title, Option<Arc<str>>>> {
        self.pages.write().expect("template store lock poisoned")
    }
}

impl TemplateSource for InMemoryTemplateStore {
    fn fetch(&self, title: &Title) -> TemplateFetch {
        match self.read().get(title) {
            Some(Some(text)) => TemplateFetch::Found(Arc::clone(text)),
            Some(None) => TemplateFetch::Missing,
            None => TemplateFetch::Unknown,
        }
    }
}

/// A page-existence fact returned by an [`ExistenceProvider`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExistenceRecord {
    /// Whether the page exists.
    pub exists: bool,
    /// For category pages: whether the category is hidden (`__HIDDENCAT__`), when known.
    pub hidden_category: Option<bool>,
}

/// A batch of existence facts, keyed by title, applied to a parsed document via
/// [`ExpandedDocument::apply_existence`](crate::ExpandedDocument::apply_existence).
#[derive(Debug, Clone, Default)]
pub struct ExistenceMap {
    records: BTreeMap<Title, ExistenceRecord>,
}

impl ExistenceMap {
    /// An empty map.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Records a fact for `title`.
    pub fn insert(&mut self, title: Title, record: ExistenceRecord) {
        self.records.insert(title, record);
    }

    /// The fact recorded for `title`, if any.
    #[must_use]
    pub fn get(&self, title: &Title) -> Option<ExistenceRecord> {
        self.records.get(title).copied()
    }

    /// Number of recorded facts.
    #[must_use]
    pub fn len(&self) -> usize {
        self.records.len()
    }

    /// Whether the map holds no facts.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.records.is_empty()
    }
}

impl FromIterator<(Title, ExistenceRecord)> for ExistenceMap {
    fn from_iter<I: IntoIterator<Item = (Title, ExistenceRecord)>>(iter: I) -> Self {
        Self {
            records: iter.into_iter().collect(),
        }
    }
}

/// Opaque, caller-defined error from an [`ExistenceProvider`] lookup.
#[derive(Debug, thiserror::Error)]
#[error("existence lookup failed: {0}")]
pub struct ProviderError(pub String);

/// A synchronous page-existence oracle consulted by `{{#ifexist:}}` during expansion.
/// Optional: without one, `{{#ifexist:}}` records the query and takes the false branch.
pub trait ExistenceProvider: Send + Sync {
    /// Looks up whether each title exists. Called from within the synchronous expander, so
    /// implementations should answer from a cache rather than block.
    fn lookup(&self, titles: &[Title]) -> Result<ExistenceMap, ProviderError>;
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::{NamespaceDef, SiteConfig};

    fn config() -> SiteConfig {
        SiteConfig::builder("en", "Wiki")
            .namespace(NamespaceDef::main())
            .namespace(NamespaceDef::new(
                10,
                "Template",
                "Template",
                Vec::<String>::new(),
                true,
            ))
            .build()
            .unwrap()
    }

    fn title(text: &str) -> Title {
        Title::parse(text, &config()).unwrap()
    }

    #[test]
    fn empty_source_answers_unknown() {
        assert_eq!(
            EmptyTemplateSource.fetch(&title("Template:X")),
            TemplateFetch::Unknown
        );
    }

    #[test]
    fn in_memory_store_distinguishes_found_missing_unknown() {
        let store = InMemoryTemplateStore::new();
        assert!(store.is_empty());
        store.insert(title("Template:A"), "body A");
        store.insert_missing(title("Template:B"));

        assert_eq!(
            store.fetch(&title("Template:A")),
            TemplateFetch::Found(Arc::from("body A"))
        );
        assert_eq!(store.fetch(&title("Template:B")), TemplateFetch::Missing);
        assert_eq!(store.fetch(&title("Template:C")), TemplateFetch::Unknown);
        assert_eq!(store.len(), 2);
        assert!(!store.is_empty());
    }

    #[test]
    fn extend_bulk_loads_present_and_missing() {
        let store = InMemoryTemplateStore::new();
        store.extend([
            (title("Template:A"), Some(Arc::from("a"))),
            (title("Template:B"), None),
        ]);
        assert_eq!(
            store.fetch(&title("Template:A")),
            TemplateFetch::Found(Arc::from("a"))
        );
        assert_eq!(store.fetch(&title("Template:B")), TemplateFetch::Missing);
        assert!(format!("{store:?}").contains("InMemoryTemplateStore"));
    }

    #[test]
    fn existence_map_records_and_reads_facts() {
        let mut map = ExistenceMap::new();
        assert!(map.is_empty());
        map.insert(
            title("A"),
            ExistenceRecord {
                exists: true,
                hidden_category: Some(false),
            },
        );
        assert_eq!(
            map.get(&title("A")),
            Some(ExistenceRecord {
                exists: true,
                hidden_category: Some(false)
            })
        );
        assert_eq!(map.get(&title("B")), None);
        assert_eq!(map.len(), 1);

        let collected: ExistenceMap = [(
            title("C"),
            ExistenceRecord {
                exists: false,
                hidden_category: None,
            },
        )]
        .into_iter()
        .collect();
        assert_eq!(collected.get(&title("C")).map(|r| r.exists), Some(false));
        assert!(format!("{map:?}").contains("ExistenceMap"));
    }

    #[test]
    fn generic_inserts_and_collectors_monomorphize_over_owned_and_borrowed() {
        let store = InMemoryTemplateStore::new();
        // insert with an owned String (a distinct monomorphization from &str).
        store.insert(title("Template:Owned"), String::from("owned body"));
        // insert with an Arc<str>.
        store.insert(title("Template:Arced"), Arc::<str>::from("arc body"));
        assert_eq!(
            store.fetch(&title("Template:Owned")),
            TemplateFetch::Found(Arc::from("owned body"))
        );

        // extend over a Vec iterator.
        let pages: Vec<(Title, Option<Arc<str>>)> = vec![
            (title("Template:V1"), Some(Arc::from("v1"))),
            (title("Template:V2"), None),
        ];
        store.extend(pages);
        assert_eq!(store.fetch(&title("Template:V2")), TemplateFetch::Missing);

        // FromIterator over a Vec iterator.
        let map: ExistenceMap = vec![(
            title("Template:V1"),
            ExistenceRecord {
                exists: true,
                hidden_category: None,
            },
        )]
        .into_iter()
        .collect();
        assert_eq!(map.get(&title("Template:V1")).map(|r| r.exists), Some(true));
    }

    #[test]
    fn provider_error_renders() {
        let err = ProviderError("api down".to_owned());
        assert_eq!(err.to_string(), "existence lookup failed: api down");
    }

    #[test]
    fn fetch_and_records_are_debuggable_and_comparable() {
        assert_ne!(TemplateFetch::Missing, TemplateFetch::Unknown);
        assert_eq!(
            format!(
                "{:?}",
                ExistenceRecord {
                    exists: true,
                    hidden_category: None
                }
            ),
            format!(
                "{:?}",
                ExistenceRecord {
                    exists: true,
                    hidden_category: None
                }
            )
        );
        assert!(EmptyTemplateSource.fetch(&title("X")) == TemplateFetch::Unknown);
        let _ = EmptyTemplateSource;
    }
}