wikioxide 0.1.0

MediaWiki wikitext parser producing typed domain models (templates, links, categories, warnings) with template expansion — no HTML, no network
Documentation
//! Expansion harness: runs every self-contained bundle under `fixtures/expand/` through
//! `WikitextParser::expand` and asserts exactly the keys each `expected.json` declares.
//!
//! Each bundle carries its own template/module sources (`templates/<Ns>__<Name>.wikitext`),
//! optional red links (`missing.txt`), a `meta.toml` (config, page title, frozen clock) and
//! the post-expansion expectations. `templates` in `expected.json` means the recursive
//! transclusion closure; existence facts for `{{#ifexist:}}` are derived from which pages
//! the bundle provides versus lists as missing.
#![cfg(feature = "presets")]
// This is an integration-test harness: `expect` on fixture I/O is the intended
// fail-loud-on-broken-fixture behavior (clippy's allow-expect-in-tests covers only
// `#[test]` bodies, not the harness helpers).
#![allow(clippy::expect_used)]

use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use serde_json::Value;
use wikioxide::{
    Clock, DocumentView, ExistenceMap, ExistenceProvider, ExistenceRecord, ExpandOutcome,
    ExpandedDocument, InMemoryTemplateStore, PageContext, ProviderError, SiteConfig, Timestamp,
    Title, WikitextParser,
};

fn fixtures_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/expand")
}

/// A fixed clock so `{{CURRENT*}}`/`{{#time:}}` fixtures are reproducible.
struct Fixed(Timestamp);
impl Clock for Fixed {
    fn now(&self) -> Timestamp {
        self.0
    }
}

/// Existence oracle: a title exists iff the bundle provided it (and it is not on the
/// missing list). Category pages are never hidden in fixtures.
struct BundleExistence {
    existing: BTreeSet<String>,
}
impl ExistenceProvider for BundleExistence {
    fn lookup(&self, titles: &[Title]) -> Result<ExistenceMap, ProviderError> {
        let mut map = ExistenceMap::new();
        for title in titles {
            map.insert(
                title.clone(),
                ExistenceRecord {
                    exists: self.existing.contains(&title.text()),
                    hidden_category: Some(false),
                },
            );
        }
        Ok(map)
    }
}

#[test]
fn expansion_fixtures_match_expectations() {
    let mut dirs: Vec<PathBuf> = fs::read_dir(fixtures_dir())
        .expect("fixtures/expand must exist")
        .filter_map(|e| {
            let path = e.ok()?.path();
            path.is_dir().then_some(path)
        })
        .collect();
    dirs.sort();
    assert!(
        dirs.len() >= 18,
        "expected ≥18 bundles, found {}",
        dirs.len()
    );

    let mut failures = Vec::new();
    for dir in &dirs {
        run_bundle(dir, &mut failures);
    }
    assert!(
        failures.is_empty(),
        "{} expansion mismatches:\n{}",
        failures.len(),
        failures.join("\n")
    );
}

fn run_bundle(dir: &Path, failures: &mut Vec<String>) {
    let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("?");
    let Ok(page) = fs::read_to_string(dir.join("page.wikitext")) else {
        return; // not a bundle directory
    };
    let expected: Value = serde_json::from_str(
        &fs::read_to_string(dir.join("expected.json")).expect("expected.json readable"),
    )
    .expect("expected.json valid");
    let meta = read_meta(&dir.join("meta.toml"));

    let config = match meta.config.as_str() {
        "ruwiki" => SiteConfig::ruwiki(),
        _ => SiteConfig::enwiki(),
    };

    // Build the template store from templates/, and track which titles exist.
    let store = Arc::new(InMemoryTemplateStore::new());
    let mut existing: BTreeSet<String> = BTreeSet::new();
    let templates_dir = dir.join("templates");
    if templates_dir.is_dir() {
        for entry in fs::read_dir(&templates_dir).expect("templates dir") {
            let path = entry.expect("dir entry").path();
            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
                continue;
            };
            let title_text = decode_title(stem);
            let Ok(title) = Title::parse(&title_text, config) else {
                continue;
            };
            // Template source files carry an editor-added trailing newline that a real
            // wiki template page would not; strip one so transcluded output matches.
            let body = fs::read_to_string(&path).expect("template body");
            let body = body.strip_suffix('\n').unwrap_or(&body).to_owned();
            existing.insert(title.text());
            store.insert(title, body);
        }
    }
    // Red links: verified-missing pages.
    let missing_path = dir.join("missing.txt");
    if let Ok(text) = fs::read_to_string(&missing_path) {
        for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
            if let Ok(title) = Title::parse(line, config) {
                store.insert_missing(title);
            }
        }
    }

    let existence = Arc::new(BundleExistence { existing });
    let parser = WikitextParser::builder()
        .site_config(config.clone())
        .template_source(store)
        .existence_provider(existence)
        .clock(Arc::new(Fixed(Timestamp::from_unix(meta.now_unix))))
        .build()
        .expect("parser builds");

    let ctx = match Title::parse(&meta.page_title, config) {
        Ok(title) => PageContext::for_title(title),
        Err(_) => PageContext::default(),
    };
    let doc = match parser.expand(&page, &ctx) {
        ExpandOutcome::Complete(doc) => doc,
        ExpandOutcome::MissingTemplates(m) => {
            failures.push(format!(
                "[{name}] unexpected MissingTemplates: {:?}",
                m.titles().iter().map(Title::text).collect::<Vec<_>>()
            ));
            return;
        }
    };
    check(name, &doc, &expected, failures);
}

fn check(name: &str, doc: &ExpandedDocument, expected: &Value, failures: &mut Vec<String>) {
    let mut fail = |key: &str, detail: String| failures.push(format!("[{name}] {key}: {detail}"));

    if let Some(want) = expected["templates"].as_array() {
        let got: Vec<String> = doc.transclusion_closure().iter().map(Title::text).collect();
        let want: Vec<String> = want
            .iter()
            .filter_map(|t| t["title"].as_str().map(str::to_owned))
            .collect();
        if got != want {
            fail("templates(closure)", format!("want {want:?}, got {got:?}"));
        }
    }

    if let Some(want) = expected["categories"].as_array() {
        let got: Vec<(String, String)> = doc
            .categories()
            .iter()
            .map(|c| (c.category().text(), c.sortkey().to_owned()))
            .collect();
        let want: Vec<(String, String)> = want
            .iter()
            .map(|c| {
                (
                    c["text"].as_str().unwrap_or_default().to_owned(),
                    c["sortkey"].as_str().unwrap_or_default().to_owned(),
                )
            })
            .collect();
        if got != want {
            fail("categories", format!("want {want:?}, got {got:?}"));
        }
    }

    if let Some(want) = expected["links"].as_array() {
        let got: Vec<String> = doc.links().iter().map(|l| l.target().text()).collect();
        let want: Vec<String> = want
            .iter()
            .filter_map(|l| l["text"].as_str().map(str::to_owned))
            .collect();
        if got != want {
            fail("links", format!("want {want:?}, got {got:?}"));
        }
    }

    if let Some(want) = expected["external_links"].as_array() {
        let got: Vec<&str> = doc.external_links().iter().map(|e| e.url()).collect();
        let want: Vec<&str> = want.iter().filter_map(Value::as_str).collect();
        if got != want {
            fail("external_links", format!("want {want:?}, got {got:?}"));
        }
    }

    if let Some(want) = expected["modules"].as_array() {
        let got: Vec<(String, String)> = doc
            .modules()
            .iter()
            .map(|m| (m.module().text(), m.function().to_owned()))
            .collect();
        let want: Vec<(String, String)> = want
            .iter()
            .map(|m| {
                (
                    m["module"].as_str().unwrap_or_default().to_owned(),
                    m["function"].as_str().unwrap_or_default().to_owned(),
                )
            })
            .collect();
        if got != want {
            fail("modules", format!("want {want:?}, got {got:?}"));
        }
    }

    if let Some(want) = expected["plaintext_equals"].as_str() {
        if doc.plain_text() != want {
            fail(
                "plaintext_equals",
                format!("want {want:?}, got {:?}", doc.plain_text()),
            );
        }
    }

    if let Some(subs) = expected["plaintext_contains"].as_array() {
        for sub in subs.iter().filter_map(Value::as_str) {
            if !doc.plain_text().contains(sub) {
                fail(
                    "plaintext_contains",
                    format!("missing {sub:?}; got {:?}", doc.plain_text()),
                );
            }
        }
    }

    if let Some(subs) = expected["warnings_contains"].as_array() {
        let rendered: Vec<String> = doc.warnings().iter().map(ToString::to_string).collect();
        for sub in subs.iter().filter_map(Value::as_str) {
            if !rendered
                .iter()
                .any(|w| w.to_lowercase().contains(&sub.to_lowercase()))
            {
                fail(
                    "warnings_contains",
                    format!("no warning contains {sub:?}; warnings {rendered:?}"),
                );
            }
        }
    }
}

/// Minimal `meta.toml` fields.
struct Meta {
    config: String,
    page_title: String,
    now_unix: i64,
}

/// Parses the handful of scalar keys the bundles use (no TOML dependency needed).
fn read_meta(path: &Path) -> Meta {
    let text = fs::read_to_string(path).unwrap_or_default();
    let mut config = "enwiki".to_owned();
    let mut page_title = "Sandbox".to_owned();
    let mut now_unix = 0_i64;
    for line in text.lines() {
        let Some((key, value)) = line.split_once('=') else {
            continue;
        };
        let key = key.trim();
        let value = value.trim().trim_matches('"');
        match key {
            "config" => config = value.to_owned(),
            "page_title" => page_title = value.to_owned(),
            "now_unix" => now_unix = value.parse().unwrap_or(0),
            _ => {}
        }
    }
    Meta {
        config,
        page_title,
        now_unix,
    }
}

/// Decodes a template filename stem into a title: `Template__Infobox_person` →
/// `Template:Infobox person` (double underscore splits namespace from name; single
/// underscores in the name become spaces).
fn decode_title(stem: &str) -> String {
    match stem.split_once("__") {
        Some((ns, name)) => format!("{ns}:{}", name.replace('_', " ")),
        None => stem.replace('_', " "),
    }
}