mini-docs 0.4.2

A minimal, secure build-time Markdown to HTML generator for the mini-* family.
Documentation
use std::fs;
use std::thread::sleep;
use std::time::Duration;

use mini_docs::{Builder, DocError, MarkdownAnalyzer};
use serde_json::{json, Value};

const MTIME_SETTLE: Duration = Duration::from_millis(50);

/// A simple analyzer that counts words.
struct WordCountAnalyzer;

impl MarkdownAnalyzer for WordCountAnalyzer {
    fn analyze(&self, body: &str, _frontmatter: &Value) -> Result<Value, DocError> {
        let word_count = body.split_whitespace().count();
        Ok(json!({
            "word_count": word_count
        }))
    }

    fn name(&self) -> &str {
        "word_count"
    }
}

/// An analyzer that estimates reading time.
struct ReadingTimeAnalyzer;

impl MarkdownAnalyzer for ReadingTimeAnalyzer {
    fn analyze(&self, body: &str, _frontmatter: &Value) -> Result<Value, DocError> {
        let word_count = body.split_whitespace().count();
        let minutes = std::cmp::max(1, word_count / 200); // ~200 words per minute
        Ok(json!({
            "estimated_minutes": minutes
        }))
    }

    fn name(&self) -> &str {
        "reading_time"
    }
}

/// An analyzer with a conflicting name.
struct ConflictAnalyzer;

impl MarkdownAnalyzer for ConflictAnalyzer {
    fn analyze(&self, _body: &str, _frontmatter: &Value) -> Result<Value, DocError> {
        Ok(json!({}))
    }

    fn name(&self) -> &str {
        "word_count" // intentionally conflicts with WordCountAnalyzer
    }
}

/// Analyzer results are merged into the Tera context and accessible in templates.
#[test]
fn analyzer_output_accessible_in_template() {
    let input = tempfile::tempdir().expect("create input tempdir");
    let templates = tempfile::tempdir().expect("create templates tempdir");
    let output = tempfile::tempdir().expect("create output tempdir");

    fs::write(
        input.path().join("page.md"),
        "# Test\n\nOne two three four five.\n",
    )
    .expect("write page.md");

    fs::write(
        templates.path().join("page.html"),
        "Word count: {{ page.extensions.word_count.word_count }}",
    )
    .expect("write page.html");

    Builder::new(input.path())
        .templates(templates.path())
        .output(output.path())
        .default_template("page.html")
        .analyzer(WordCountAnalyzer)
        .build()
        .expect("build should succeed");

    let written = fs::read_to_string(output.path().join("page.html")).expect("read output file");
    // Markdown "# Test\n\nOne two three four five.\n" splits as: "#", "Test", "One", "two", "three", "four", "five" = 7
    assert!(
        written.contains("Word count: 7"),
        "analyzer output should be rendered in template: {written}"
    );
}

/// Two analyzers with the same name produce DocError::Extension from build() before any page is written.
#[test]
fn duplicate_analyzer_names_cause_error() {
    let input = tempfile::tempdir().expect("create input tempdir");
    let templates = tempfile::tempdir().expect("create templates tempdir");
    let output = tempfile::tempdir().expect("create output tempdir");

    fs::write(input.path().join("page.md"), "# Test\n\nBody.\n").expect("write page.md");
    fs::write(
        templates.path().join("page.html"),
        "{{ page.content | safe }}",
    )
    .expect("write page.html");

    let result = Builder::new(input.path())
        .templates(templates.path())
        .output(output.path())
        .default_template("page.html")
        .analyzer(WordCountAnalyzer)
        .analyzer(ConflictAnalyzer)
        .build();

    match result {
        Err(DocError::Extension(msg)) => {
            assert!(
                msg.contains("word_count"),
                "error message should mention the conflicting name: {msg}"
            );
        }
        _ => panic!("expected DocError::Extension with duplicate name, got {result:?}"),
    }

    // verify no output was written
    assert!(
        !output.path().join("page.html").exists(),
        "no output should be written when build fails"
    );
}

/// An analyzer registered on Builder works through the watch() / tick() path too.
#[test]
fn analyzer_works_in_watch_path() {
    let input = tempfile::tempdir().expect("create input tempdir");
    let templates = tempfile::tempdir().expect("create templates tempdir");
    let output = tempfile::tempdir().expect("create output tempdir");

    fs::write(
        input.path().join("page.md"),
        "# Test\n\nOne two three.\n",
    )
    .expect("write page.md");

    fs::write(
        templates.path().join("page.html"),
        "Count: {{ page.extensions.word_count.word_count }}",
    )
    .expect("write page.html");

    let builder = Builder::new(input.path())
        .templates(templates.path())
        .output(output.path())
        .default_template("page.html")
        .analyzer(WordCountAnalyzer);

    let mut watcher = builder.watch().expect("initial watch build should succeed");

    let initial = fs::read_to_string(output.path().join("page.html")).expect("read output file");
    // Markdown "# Test\n\nOne two three.\n" splits as: "#", "Test", "One", "two", "three" = 5
    assert!(
        initial.contains("Count: 5"),
        "analyzer should run during initial watch build: {initial}"
    );

    sleep(MTIME_SETTLE);
    fs::write(
        input.path().join("page.md"),
        "# Test\n\nOne two three four five.\n",
    )
    .expect("rewrite page.md");

    let changed = watcher.tick().expect("tick should succeed");
    assert_eq!(
        changed.len(),
        1,
        "the page should be rebuilt on tick: {changed:?}"
    );

    let after = fs::read_to_string(output.path().join("page.html")).expect("read updated output");
    // Updated markdown "# Test\n\nOne two three four five.\n" = 7 words
    assert!(
        after.contains("Count: 7"),
        "analyzer should run on watch tick with updated word count: {after}"
    );
}

/// Multiple analyzers run and all results appear in the template context.
#[test]
fn multiple_analyzers_results_merged() {
    let input = tempfile::tempdir().expect("create input tempdir");
    let templates = tempfile::tempdir().expect("create templates tempdir");
    let output = tempfile::tempdir().expect("create output tempdir");

    // 1000 words = ~5 minutes of reading time at 200 wpm
    let body = (0..100).map(|i| format!("word{}", i)).collect::<Vec<_>>().join(" ");
    fs::write(input.path().join("page.md"), &format!("# Test\n\n{body}\n"))
        .expect("write page.md");

    fs::write(
        templates.path().join("page.html"),
        "Words: {{ page.extensions.word_count.word_count }}, Time: {{ page.extensions.reading_time.estimated_minutes }}m",
    )
    .expect("write page.html");

    Builder::new(input.path())
        .templates(templates.path())
        .output(output.path())
        .default_template("page.html")
        .analyzer(WordCountAnalyzer)
        .analyzer(ReadingTimeAnalyzer)
        .build()
        .expect("build should succeed");

    let written = fs::read_to_string(output.path().join("page.html")).expect("read output file");
    // 100 words from generator + "#" + "Test" = 102 words
    assert!(
        written.contains("Words: 102"),
        "word count analyzer should be in context: {written}"
    );
    assert!(
        written.contains("Time:"),
        "reading time analyzer should be in context: {written}"
    );
}

/// Processors run before analyzers: analyzer sees the processed body.
#[test]
fn analyzers_receive_processed_body_from_processors() {
    use mini_docs::MarkdownProcessor;

    let input = tempfile::tempdir().expect("create input tempdir");
    let templates = tempfile::tempdir().expect("create templates tempdir");
    let output = tempfile::tempdir().expect("create output tempdir");

    fs::write(input.path().join("page.md"), "# Test\n\nOriginal body.\n")
        .expect("write page.md");

    fs::write(
        templates.path().join("page.html"),
        "Count: {{ page.extensions.word_count.word_count }}",
    )
    .expect("write page.html");

    // Processor appends extra content
    struct AppendProcessor;
    impl MarkdownProcessor for AppendProcessor {
        fn process(&self, body: &str, _frontmatter: &Value) -> Result<String, DocError> {
            Ok(format!("{body}\n\nAppended content here."))
        }
        fn name(&self) -> &str {
            "appender"
        }
    }

    Builder::new(input.path())
        .templates(templates.path())
        .output(output.path())
        .default_template("page.html")
        .processor(AppendProcessor)
        .analyzer(WordCountAnalyzer)
        .build()
        .expect("build should succeed");

    let written = fs::read_to_string(output.path().join("page.html")).expect("read output file");
    // Original: "# Test\n\nOriginal body.\n"
    // After processor appends: "# Test\n\nOriginal body.\n\nAppended content here."
    // Split by whitespace: "#", "Test", "Original", "body", "Appended", "content", "here" = 7 words
    assert!(
        written.contains("Count: 7"),
        "analyzer should see the processed body (including appended content): {written}"
    );
}