use crate::markdown::{CodeBlock, Doc};
pub const GENERATED_HEADER: &str = "// @generated by `cargo soothfast docs gen-tests`";
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")
}
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, " ");
let call_main = if block.code.contains("fn main") {
"\n main();"
} else {
""
};
let cfg = match block.feature_cfg() {
Some(pred) => format!("#[cfg({pred})]\n"),
None => String::new(),
};
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)
}
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) {
(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!")); 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() {
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;
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() {
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); assert!(content.contains("mod soothfast_capture_body"));
assert!(content.contains("pub use soothfast_capture_body::main;"));
assert!(content.contains("pub async fn main()")); assert!(content.contains("#[cfg(not(feature = \"test-utils\"))]\nfn main() {}"));
assert!(content.contains("use std::fmt;"));
}
}