use super::loader::Config;
use super::source::parse_user_kdl;
use std::path::{Path, PathBuf};
#[derive(Debug, PartialEq, Eq)]
struct FencedBlock {
line: usize,
info: String,
body: String,
}
fn extract_fenced_blocks(md: &str) -> Vec<FencedBlock> {
let mut blocks = Vec::new();
let mut open: Option<FencedBlock> = None;
for (i, line) in md.lines().enumerate() {
let trimmed = line.trim_start();
match open.as_mut() {
Some(block) => {
if trimmed.starts_with("```") {
blocks.push(open.take().expect("checked above"));
} else {
block.body.push_str(line);
block.body.push('\n');
}
}
None => {
if let Some(info) = trimmed.strip_prefix("```") {
open = Some(FencedBlock {
line: i + 1,
info: info.trim().to_string(),
body: String::new(),
});
}
}
}
}
blocks
}
fn guide_chapter_files() -> Vec<PathBuf> {
let guide = Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/guide");
let mut files = Vec::new();
collect_chapters(&guide, false, &mut files);
files.sort();
files
}
fn collect_chapters(dir: &Path, in_src: bool, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
if path.is_dir() {
if name == "book" {
continue; }
collect_chapters(&path, in_src || name == "src", out);
} else if in_src && path.extension().is_some_and(|e| e == "md") {
out.push(path);
}
}
}
#[test]
fn every_guide_kdl_example_loads_as_a_user_config() {
let files = guide_chapter_files();
if files.is_empty() {
if !Path::new(env!("CARGO_MANIFEST_DIR")).join(".git").exists() {
eprintln!("guide examples: docs/guide not present (packaged source) — skipping");
return;
}
panic!("no guide chapters found — was docs/guide moved?");
}
let mut verified = 0usize;
let mut failures = Vec::new();
for file in &files {
let md = std::fs::read_to_string(file)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", file.display()));
for block in extract_fenced_blocks(&md) {
if block.info != "kdl" {
continue; }
let origin = format!("{}:{}", file.display(), block.line);
let result = parse_user_kdl(&block.body, Path::new(&origin))
.and_then(|doc| Config::with_user(&doc, PathBuf::from(&origin)));
match result {
Ok(_) => verified += 1,
Err(e) => failures.push(format!("{origin}: {e:#}")),
}
}
}
assert!(
failures.is_empty(),
"guide config examples failed to load:\n{}",
failures.join("\n")
);
assert!(
verified >= 30,
"only {verified} ```kdl examples found — extraction is likely broken"
);
}
#[cfg(test)]
mod extractor_tests {
use super::*;
#[test]
fn extracts_blocks_with_info_and_line_numbers() {
let md = "# T\n\n```kdl\ntheme \"x\"\n```\n\ntext\n\n```bash\nls\n```\n";
let blocks = extract_fenced_blocks(md);
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].line, 3);
assert_eq!(blocks[0].info, "kdl");
assert_eq!(blocks[0].body, "theme \"x\"\n");
assert_eq!(blocks[1].line, 9);
assert_eq!(blocks[1].info, "bash");
assert_eq!(blocks[1].body, "ls\n");
}
#[test]
fn ignore_annotation_is_a_distinct_info_string() {
let md = "```kdl,ignore\nbroken (\n```\n```kdl\nicons \"none\"\n```\n";
let blocks = extract_fenced_blocks(md);
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].info, "kdl,ignore");
assert_eq!(blocks[1].info, "kdl");
}
#[test]
fn indented_fences_and_unclosed_blocks() {
let md = "- item\n ```kdl\n theme \"x\"\n ```\n```text\ndangling\n";
let blocks = extract_fenced_blocks(md);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].info, "kdl");
assert_eq!(blocks[0].body, " theme \"x\"\n");
}
#[test]
fn text_between_blocks_is_not_captured() {
let md = "```kdl\na \"1\"\n```\nnot in a block\n```kdl\nb \"2\"\n```\n";
let blocks = extract_fenced_blocks(md);
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].body, "a \"1\"\n");
assert_eq!(blocks[1].body, "b \"2\"\n");
}
#[test]
fn finds_the_guide_chapters() {
let files = guide_chapter_files();
let names: Vec<String> = files
.iter()
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect();
assert!(
names.iter().filter(|n| *n == "config-recipes.md").count() >= 2,
"expected config-recipes.md in en and ja, got: {names:?}"
);
}
}