soothfast_docs/
gentests.rs1use crate::markdown::{CodeBlock, Doc};
6
7pub const GENERATED_HEADER: &str = "// @generated by `cargo soothfast docs gen-tests`";
10
11pub fn sanitized_stem(rel_path: &str) -> String {
13 let stem = rel_path.trim_end_matches(".md");
14 let san: String = stem
15 .chars()
16 .map(|c| {
17 if c.is_ascii_alphanumeric() {
18 c.to_ascii_lowercase()
19 } else {
20 '_'
21 }
22 })
23 .collect();
24 san.trim_matches('_').to_string()
25}
26
27fn indent(code: &str, pad: &str) -> String {
28 code.lines()
29 .map(|l| {
30 if l.is_empty() {
31 String::new()
32 } else {
33 format!("{pad}{l}")
34 }
35 })
36 .collect::<Vec<_>>()
37 .join("\n")
38}
39
40pub fn test_file(rel_path: &str, doc: &Doc) -> Option<String> {
43 let testable: Vec<&CodeBlock> = doc.blocks.iter().filter(|b| b.is_testable_rust()).collect();
44 if testable.is_empty() {
45 return None;
46 }
47 let mut out = format!("{GENERATED_HEADER}\n// source: {rel_path}\n#![allow(unused)]\n");
48 for block in testable {
49 let body = indent(&block.code, " ");
50 let call_main = if block.code.contains("fn main") {
52 "\n main();"
53 } else {
54 ""
55 };
56 let cfg = match block.feature_tag() {
58 Some(f) => format!("#[cfg(feature = \"{f}\")]\n"),
59 None => String::new(),
60 };
61 if block.has_tag("no_run") {
65 out.push_str(&format!(
66 "\n// line {}: compile-only (no_run)\n{cfg}#[rustfmt::skip]\n#[allow(dead_code)]\nfn doc_block_line_{}() {{\n{body}\n}}\n",
67 block.line, block.line
68 ));
69 } else {
70 out.push_str(&format!(
71 "\n// line {}\n{cfg}#[rustfmt::skip]\n#[test]\nfn doc_block_line_{}() {{\n{body}{call_main}\n}}\n",
72 block.line, block.line
73 ));
74 }
75 }
76 Some(out)
77}
78
79pub fn capture_examples(rel_path: &str, doc: &Doc) -> Vec<(String, String)> {
89 let stem = sanitized_stem(rel_path);
90 doc.blocks
91 .iter()
92 .filter(|b| b.is_capture())
93 .enumerate()
94 .map(|(n, block)| {
95 let name = format!("soothfast_capture_{stem}_{}", n + 1);
96 let has_main = block.code.contains("fn main");
97 let body = match (block.feature_tag(), has_main) {
98 (Some(f), true) => {
107 let pub_main = if block.code.contains("async fn main") {
108 block.code.replacen("async fn main", "pub async fn main", 1)
109 } else {
110 block.code.replacen("fn main", "pub fn main", 1)
111 };
112 format!(
113 "#[cfg(feature = \"{f}\")]\nmod soothfast_capture_body {{\n{}\n}}\n#[cfg(feature = \"{f}\")]\npub use soothfast_capture_body::main;\n\n#[cfg(not(feature = \"{f}\"))]\nfn main() {{}}\n",
114 indent(&pub_main, " ")
115 )
116 }
117 (Some(f), false) => format!(
118 "#[cfg(feature = \"{f}\")]\nfn main() {{\n{}\n}}\n\n#[cfg(not(feature = \"{f}\"))]\nfn main() {{}}\n",
119 indent(&block.code, " ")
120 ),
121 (None, true) => block.code.clone(),
122 (None, false) => format!("fn main() {{\n{}\n}}\n", indent(&block.code, " ")),
123 };
124 let content =
125 format!("{GENERATED_HEADER}\n// source: {rel_path} line {}\n{body}", block.line);
126 (name, content)
127 })
128 .collect()
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use crate::markdown;
135
136 #[test]
137 fn generates_tests_and_examples() {
138 let md = "```rust\nlet x = 1;\n```\n```rust no_run\nloop {}\n```\n```rust capture-output\nprintln!(\"h\");\n```\n";
139 let doc = markdown::scan(md).unwrap();
140 let tests = test_file("docs/a.md", &doc).unwrap();
141 assert!(tests.contains("#[test]"));
142 assert!(tests.contains("compile-only"));
143 assert!(!tests.contains("println!")); let examples = capture_examples("docs/a.md", &doc);
145 assert_eq!(examples.len(), 1);
146 assert_eq!(examples[0].0, "soothfast_capture_docs_a_1");
147 assert!(examples[0].1.contains("fn main()"));
148 }
149
150 #[test]
151 fn all_ignored_means_no_test_file() {
152 let doc = markdown::scan("```rust ignore\nfictional();\n```\n").unwrap();
153 assert!(test_file("README.md", &doc).is_none());
154 }
155
156 #[test]
157 fn feature_tag_gates_generated_test() {
158 let md =
159 "```rust feature=risk\nlet x = 1;\n```\n```rust no_run feature=risk\nloop {}\n```\n";
160 let doc = markdown::scan(md).unwrap();
161 let tests = test_file("docs/r.md", &doc).unwrap();
162 assert_eq!(tests.matches("#[cfg(feature = \"risk\")]").count(), 2);
163 }
164
165 #[test]
166 fn feature_tag_gates_capture_example_with_a_noop_fallback() {
167 let md = "```rust capture-output feature=crypto\nprintln!(\"hi\");\n```\n";
168 let doc = markdown::scan(md).unwrap();
169 let examples = capture_examples("docs/c.md", &doc);
170 assert_eq!(examples.len(), 1);
171 let content = &examples[0].1;
172 assert!(content.contains("#[cfg(feature = \"crypto\")]"));
176 assert!(content.contains("#[cfg(not(feature = \"crypto\"))]\nfn main() {}"));
177 assert!(content.contains("println!(\"hi\");"));
178 }
179
180 #[test]
181 fn feature_tag_with_own_fn_main_does_not_duplicate_main() {
182 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";
186 let doc = markdown::scan(md).unwrap();
187 let examples = capture_examples("docs/m.md", &doc);
188 assert_eq!(examples.len(), 1);
189 let content = &examples[0].1;
190 assert_eq!(content.matches("fn main").count(), 2); assert!(content.contains("mod soothfast_capture_body"));
192 assert!(content.contains("pub use soothfast_capture_body::main;"));
193 assert!(content.contains("pub async fn main()")); assert!(content.contains("#[cfg(not(feature = \"test-utils\"))]\nfn main() {}"));
195 assert!(content.contains("use std::fmt;"));
196 }
197}