soothfast-docs 0.3.2

Soothfast docs engine: rustdoc JSON surface, bind blocks, claims, doc-test generation
Documentation
//! Generated artifacts from markdown code blocks: a test file per markdown
//! source (plain rust blocks) and a runnable example per capture block.
//! CI compares regenerated content against disk, so stale artifacts fail.

use crate::markdown::{CodeBlock, Doc};

/// First line of every generated file; `docs check` uses it to tell
/// generated artifacts from hand-written ones.
pub const GENERATED_HEADER: &str = "// @generated by `cargo soothfast docs gen-tests`";

/// `docs/measuring.md` → `soothfast_doc_docs_measuring`.
pub fn sanitized_stem(rel_path: &str) -> String {
    let stem = rel_path.trim_end_matches(".md");
    let san: String = stem
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() {
                c.to_ascii_lowercase()
            } else {
                '_'
            }
        })
        .collect();
    san.trim_matches('_').to_string()
}

fn indent(code: &str, pad: &str) -> String {
    code.lines()
        .map(|l| {
            if l.is_empty() {
                String::new()
            } else {
                format!("{pad}{l}")
            }
        })
        .collect::<Vec<_>>()
        .join("\n")
}

/// Test-file content for one markdown doc, or None when it has no testable
/// blocks (all ignored / non-rust / capture).
pub fn test_file(rel_path: &str, doc: &Doc) -> Option<String> {
    let testable: Vec<&CodeBlock> = doc.blocks.iter().filter(|b| b.is_testable_rust()).collect();
    if testable.is_empty() {
        return None;
    }
    let mut out = format!("{GENERATED_HEADER}\n// source: {rel_path}\n#![allow(unused)]\n");
    for block in testable {
        let body = indent(&block.code, "    ");
        // Blocks that define `fn main` get it as a nested fn, then called.
        let call_main = if block.code.contains("fn main") {
            "\n    main();"
        } else {
            ""
        };
        // A `feature=NAME` tag gates the generated test behind that feature (`feature=A,B` behind all of them).
        let cfg = match block.feature_cfg() {
            Some(pred) => format!("#[cfg({pred})]\n"),
            None => String::new(),
        };
        // rustfmt::skip (per-item — the inner-attribute form is unstable):
        // blocks are verbatim from the markdown; formatting the generated fn
        // would make it diverge from its source and fail `docs check`.
        if block.has_tag("no_run") {
            out.push_str(&format!(
                "\n// line {}: compile-only (no_run)\n{cfg}#[rustfmt::skip]\n#[allow(dead_code)]\nfn doc_block_line_{}() {{\n{body}\n}}\n",
                block.line, block.line
            ));
        } else {
            out.push_str(&format!(
                "\n// line {}\n{cfg}#[rustfmt::skip]\n#[test]\nfn doc_block_line_{}() {{\n{body}{call_main}\n}}\n",
                block.line, block.line
            ));
        }
    }
    Some(out)
}

/// Example-file contents for the capture blocks of one markdown doc, in
/// capture order: (example_name, content).
///
/// A `feature=NAME` tag gates the real `fn main` behind that feature
/// (`feature=A,B` behind all of them)
pub fn capture_examples(rel_path: &str, doc: &Doc) -> Vec<(String, String)> {
    let stem = sanitized_stem(rel_path);
    doc.blocks
        .iter()
        .filter(|b| b.is_capture())
        .enumerate()
        .map(|(n, block)| {
            let name = format!("soothfast_capture_{stem}_{}", n + 1);
            let has_main = block.code.contains("fn main");
            let body = match (block.feature_cfg(), has_main) {
                // A feature-gated block that already defines `fn main` may
                // also have preceding items (`use` ...): a bare `#[cfg(...)]`
                // above the verbatim block would only gate the first item,
                // leaving `fn main` unconditional and colliding with the
                // fallback below. A `mod` scopes the whole block as one
                // item; `pub use` re-exports `main` (whatever its signature/
                // attributes, e.g. `#[tokio::main]`) as the real entry point
                // — it must be `pub` first, or the re-export can't see it.
                (Some(pred), true) => {
                    let pub_main = if block.code.contains("async fn main") {
                        block.code.replacen("async fn main", "pub async fn main", 1)
                    } else {
                        block.code.replacen("fn main", "pub fn main", 1)
                    };
                    format!(
                        "#[cfg({pred})]\nmod soothfast_capture_body {{\n{}\n}}\n#[cfg({pred})]\npub use soothfast_capture_body::main;\n\n#[cfg(not({pred}))]\nfn main() {{}}\n",
                        indent(&pub_main, "    ")
                    )
                }
                (Some(pred), false) => format!(
                    "#[cfg({pred})]\nfn main() {{\n{}\n}}\n\n#[cfg(not({pred}))]\nfn main() {{}}\n",
                    indent(&block.code, "    ")
                ),
                (None, true) => block.code.clone(),
                (None, false) => format!("fn main() {{\n{}\n}}\n", indent(&block.code, "    ")),
            };
            let content =
                format!("{GENERATED_HEADER}\n// source: {rel_path} line {}\n{body}", block.line);
            (name, content)
        })
        .collect()
}

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

    #[test]
    fn generates_tests_and_examples() {
        let md = "```rust\nlet x = 1;\n```\n```rust no_run\nloop {}\n```\n```rust capture-output\nprintln!(\"h\");\n```\n";
        let doc = markdown::scan(md).unwrap();
        let tests = test_file("docs/a.md", &doc).unwrap();
        assert!(tests.contains("#[test]"));
        assert!(tests.contains("compile-only"));
        assert!(!tests.contains("println!")); // capture block excluded
        let examples = capture_examples("docs/a.md", &doc);
        assert_eq!(examples.len(), 1);
        assert_eq!(examples[0].0, "soothfast_capture_docs_a_1");
        assert!(examples[0].1.contains("fn main()"));
    }

    #[test]
    fn all_ignored_means_no_test_file() {
        let doc = markdown::scan("```rust ignore\nfictional();\n```\n").unwrap();
        assert!(test_file("README.md", &doc).is_none());
    }

    #[test]
    fn feature_tag_gates_generated_test() {
        let md =
            "```rust feature=risk\nlet x = 1;\n```\n```rust no_run feature=risk\nloop {}\n```\n";
        let doc = markdown::scan(md).unwrap();
        let tests = test_file("docs/r.md", &doc).unwrap();
        assert_eq!(tests.matches("#[cfg(feature = \"risk\")]").count(), 2);
    }

    #[test]
    fn comma_separated_feature_tag_gates_on_all_of_them() {
        // A block whose body needs two features to compile — gating on only
        // the first leaves it enabled, and failing to build, when the second
        // is off.
        let md = "```rust no_run feature=risk,polygon\nloop {}\n```\n";
        let doc = markdown::scan(md).unwrap();
        let tests = test_file("docs/r.md", &doc).unwrap();
        assert!(tests.contains("#[cfg(all(feature = \"risk\", feature = \"polygon\"))]"));
    }

    #[test]
    fn comma_separated_feature_tag_gates_capture_example_and_its_fallback() {
        let md = "```rust capture-output feature=risk,polygon\nprintln!(\"hi\");\n```\n";
        let doc = markdown::scan(md).unwrap();
        let content = &capture_examples("docs/c.md", &doc)[0].1;
        assert!(content.contains("#[cfg(all(feature = \"risk\", feature = \"polygon\"))]"));
        assert!(
            content.contains(
                "#[cfg(not(all(feature = \"risk\", feature = \"polygon\")))]\nfn main() {}"
            )
        );
    }

    #[test]
    fn feature_tag_gates_capture_example_with_a_noop_fallback() {
        let md = "```rust capture-output feature=crypto\nprintln!(\"hi\");\n```\n";
        let doc = markdown::scan(md).unwrap();
        let examples = capture_examples("docs/c.md", &doc);
        assert_eq!(examples.len(), 1);
        let content = &examples[0].1;
        // The real body only compiles in — and only prints — under the
        // feature; an example file always needs *some* fn main, so the
        // fallback keeps `cargo build --examples` green without it.
        assert!(content.contains("#[cfg(feature = \"crypto\")]"));
        assert!(content.contains("#[cfg(not(feature = \"crypto\"))]\nfn main() {}"));
        assert!(content.contains("println!(\"hi\");"));
    }

    #[test]
    fn feature_tag_with_own_fn_main_does_not_duplicate_main() {
        // A block with its own `use` + `fn main` (e.g. `#[tokio::main]`)
        // must not leave `fn main` unconditional alongside the fallback —
        // that would be two `main`s when the feature is off.
        let md = "```rust capture-output feature=test-utils\nuse std::fmt;\n\n#[tokio::main]\nasync fn main() -> Result<(), Box<dyn std::error::Error>> {\n    Ok(())\n}\n```\n";
        let doc = markdown::scan(md).unwrap();
        let examples = capture_examples("docs/m.md", &doc);
        assert_eq!(examples.len(), 1);
        let content = &examples[0].1;
        assert_eq!(content.matches("fn main").count(), 2); // real (renamed) + no-op fallback
        assert!(content.contains("mod soothfast_capture_body"));
        assert!(content.contains("pub use soothfast_capture_body::main;"));
        assert!(content.contains("pub async fn main()")); // re-exported item must be pub
        assert!(content.contains("#[cfg(not(feature = \"test-utils\"))]\nfn main() {}"));
        assert!(content.contains("use std::fmt;"));
    }
}