use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct RuleFile {
pub bundle: String,
pub rel: PathBuf,
pub frontmatter: Option<String>,
pub body: String,
pub raw: String,
}
pub fn collect_from_bundle(bundle_root: &Path, bundle_name: &str) -> anyhow::Result<Vec<RuleFile>> {
let dir = bundle_root.join("rules");
if !dir.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
walk(bundle_root, &dir, bundle_name, &mut out)?;
out.sort_by(|a, b| a.rel.cmp(&b.rel));
Ok(out)
}
fn walk(
bundle_root: &Path,
dir: &Path,
bundle_name: &str,
out: &mut Vec<RuleFile>,
) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let file_type = entry.file_type()?;
let p = entry.path();
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
walk(bundle_root, &p, bundle_name, out)?;
continue;
}
if !file_type.is_file() {
continue;
}
if p.extension().is_none_or(|e| e != "md") {
continue;
}
let raw = std::fs::read_to_string(&p)?;
let (frontmatter, body) = split_frontmatter(&raw);
let rel = p
.strip_prefix(bundle_root)
.map_err(|e| anyhow::anyhow!("path {} not under bundle root: {e}", p.display()))?
.to_path_buf();
out.push(RuleFile {
bundle: bundle_name.to_owned(),
rel,
frontmatter,
body,
raw,
});
}
Ok(())
}
fn split_frontmatter(raw: &str) -> (Option<String>, String) {
let Some(rest) = raw.strip_prefix("---\n") else {
return (None, raw.to_owned());
};
if let Some(end) = rest.find("\n---\n") {
let fm = rest[..end].to_owned();
let body = rest[end + "\n---\n".len()..].to_owned();
return (Some(fm), body);
}
if let Some(stripped) = rest.strip_suffix("\n---") {
return (Some(stripped.to_owned()), String::new());
}
(None, raw.to_owned())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn no_frontmatter_passes_through() {
let (fm, body) = split_frontmatter("# Title\nstuff\n");
assert!(fm.is_none());
assert_eq!(body, "# Title\nstuff\n");
}
#[test]
fn frontmatter_with_body() {
let raw = "---\nscope: rust\ntags: [a, b]\n---\n# Body\ntext\n";
let (fm, body) = split_frontmatter(raw);
assert_eq!(fm.as_deref(), Some("scope: rust\ntags: [a, b]"));
assert_eq!(body, "# Body\ntext\n");
}
#[test]
fn frontmatter_at_eof() {
let raw = "---\nscope: rust\n---";
let (fm, body) = split_frontmatter(raw);
assert_eq!(fm.as_deref(), Some("scope: rust"));
assert_eq!(body, "");
}
#[test]
fn unterminated_frontmatter_falls_back_to_plain() {
let raw = "---\nscope: rust\nno closer here\n";
let (fm, body) = split_frontmatter(raw);
assert!(fm.is_none());
assert_eq!(body, raw);
}
#[test]
fn empty_file_has_no_frontmatter() {
let (fm, body) = split_frontmatter("");
assert!(fm.is_none());
assert_eq!(body, "");
}
#[test]
fn collect_skips_missing_rules_dir() {
let tmp = tempfile::tempdir().unwrap();
let rules = collect_from_bundle(tmp.path(), "x").unwrap();
assert!(rules.is_empty());
}
#[test]
fn collect_finds_and_parses_md_files() {
let tmp = tempfile::tempdir().unwrap();
let rules_dir = tmp.path().join("rules");
std::fs::create_dir_all(rules_dir.join("nested")).unwrap();
std::fs::write(rules_dir.join("a.md"), "---\nscope: rust\n---\n# A\n").unwrap();
std::fs::write(rules_dir.join("b.md"), "# B no fm\n").unwrap();
std::fs::write(rules_dir.join("nested/c.md"), "# C\n").unwrap();
std::fs::write(rules_dir.join("ignored.txt"), "skipped\n").unwrap();
let rules = collect_from_bundle(tmp.path(), "pkg").unwrap();
assert_eq!(rules.len(), 3);
assert_eq!(rules[0].rel, Path::new("rules/a.md"));
assert_eq!(rules[1].rel, Path::new("rules/b.md"));
assert_eq!(rules[2].rel, Path::new("rules/nested/c.md"));
assert_eq!(rules[0].frontmatter.as_deref(), Some("scope: rust"));
assert_eq!(rules[0].body, "# A\n");
assert!(rules[1].frontmatter.is_none());
assert_eq!(rules[0].bundle, "pkg");
}
}