use crate::snippets::discovery;
use crate::snippets::gaps::{discover_includes, parse_include_target};
use crate::snippets::parser::{self, FrontmatterStatus};
use crate::snippets::types::Language;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone, Default)]
pub struct AuditConfig {
pub docs_dirs: Vec<PathBuf>,
pub snippet_dirs: Vec<PathBuf>,
pub require_frontmatter: bool,
pub include_base_paths: Vec<PathBuf>,
pub configured_references: Vec<PathBuf>,
pub exclude: Vec<PathBuf>,
pub accounting: SnippetAccounting,
}
#[derive(Debug, Clone, Default)]
pub struct SnippetAccounting {
pub generated_paths: Vec<PathBuf>,
pub curated_paths: Vec<PathBuf>,
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AuditSeverity {
Error,
Warning,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AuditIssueKind {
BrokenFrontmatter,
MissingFrontmatter,
BrokenFence,
MissingInclude,
InvalidInclude,
UnknownLanguage,
UnreadableFile,
MissingDirectory,
UnaccountedSnippet,
CuratedGeneratedSnippet,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditIssue {
pub kind: AuditIssueKind,
pub severity: AuditSeverity,
pub path: PathBuf,
pub line: usize,
pub message: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditReport {
pub issues: Vec<AuditIssue>,
#[serde(default)]
pub curated: Vec<PathBuf>,
}
impl AuditReport {
#[must_use]
pub fn has_errors(&self) -> bool {
self.issues.iter().any(|issue| issue.severity == AuditSeverity::Error)
}
}
#[must_use]
pub fn audit(config: &AuditConfig) -> AuditReport {
let mut issues = Vec::new();
issues.extend(missing_directory_issues(
discovery::SNIPPET_DIRECTORY_KIND,
&config.snippet_dirs,
));
issues.extend(missing_directory_issues(
discovery::DOCUMENTATION_DIRECTORY_KIND,
&config.docs_dirs,
));
for snippet_dir in &config.snippet_dirs {
issues.extend(audit_snippets(snippet_dir, config.require_frontmatter, &config.exclude));
}
for docs_dir in &config.docs_dirs {
issues.extend(audit_docs(docs_dir, &config.include_base_paths, &config.exclude));
}
for path in &config.configured_references {
if !path.exists() {
issues.push(issue(
AuditIssueKind::MissingInclude,
path,
1,
format!("configured README snippet does not exist: {}", path.display()),
));
}
}
let accounting = account_snippets(config);
issues.extend(accounting.issues);
issues.sort_by(|left, right| {
left.path
.cmp(&right.path)
.then(left.line.cmp(&right.line))
.then(left.message.cmp(&right.message))
});
AuditReport {
issues,
curated: accounting.curated,
}
}
struct AccountingOutcome {
issues: Vec<AuditIssue>,
curated: Vec<PathBuf>,
}
fn account_snippets(config: &AuditConfig) -> AccountingOutcome {
if !config.accounting.enabled {
return AccountingOutcome {
issues: Vec::new(),
curated: Vec::new(),
};
}
let generated: BTreeSet<&Path> = config.accounting.generated_paths.iter().map(PathBuf::as_path).collect();
let curated: BTreeSet<&Path> = config.accounting.curated_paths.iter().map(PathBuf::as_path).collect();
let mut issues = Vec::new();
for claimed in curated.intersection(&generated) {
issues.push(issue(
AuditIssueKind::CuratedGeneratedSnippet,
claimed,
1,
format!(
"curated_snippets claims `{}`, which a coverage ledger records as alef-generated; \
a curated declaration must never claim a path alef writes",
claimed.display()
),
));
}
let mut recognised_curated = Vec::new();
for snippet_dir in &config.snippet_dirs {
for path in markdown_files(snippet_dir, &config.exclude) {
if generated.contains(path.as_path()) {
continue;
}
if curated.contains(path.as_path()) {
recognised_curated.push(path);
continue;
}
issues.push(issue(
AuditIssueKind::UnaccountedSnippet,
&path,
1,
"snippet is neither recorded as alef-generated by a coverage ledger nor declared in \
[crates.e2e.snippets].curated_snippets; declare it curated or let alef generate it"
.to_string(),
));
}
}
recognised_curated.sort();
recognised_curated.dedup();
AccountingOutcome {
issues,
curated: recognised_curated,
}
}
fn missing_directory_issues(kind: &str, dirs: &[PathBuf]) -> Vec<AuditIssue> {
discovery::missing_configured_directories(dirs)
.into_iter()
.map(|directory| {
issue(
AuditIssueKind::MissingDirectory,
directory,
1,
discovery::missing_directory_message(kind, directory),
)
})
.collect()
}
fn audit_snippets(snippet_dir: &Path, require_frontmatter: bool, exclude: &[PathBuf]) -> Vec<AuditIssue> {
markdown_files(snippet_dir, exclude)
.into_iter()
.flat_map(|path| audit_snippet_file(&path, require_frontmatter))
.collect()
}
fn audit_snippet_file(path: &Path, require_frontmatter: bool) -> Vec<AuditIssue> {
let mut issues = Vec::new();
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(err) => {
issues.push(issue(
AuditIssueKind::UnreadableFile,
path,
1,
format!("failed to read snippet file: {err}"),
));
return issues;
}
};
match parser::frontmatter_status(&content) {
FrontmatterStatus::Missing if require_frontmatter => issues.push(issue(
AuditIssueKind::MissingFrontmatter,
path,
1,
"snippet markdown is missing YAML frontmatter".to_string(),
)),
FrontmatterStatus::Malformed(message) => {
issues.push(issue(AuditIssueKind::BrokenFrontmatter, path, 1, message))
}
FrontmatterStatus::Present => {}
FrontmatterStatus::Missing => {}
}
issues.extend(audit_fences(path, &content));
issues
}
fn audit_docs(docs_dir: &Path, include_base_paths: &[PathBuf], exclude: &[PathBuf]) -> Vec<AuditIssue> {
let mut issues = Vec::new();
for path in markdown_files(docs_dir, exclude) {
let content = match std::fs::read_to_string(&path) {
Ok(content) => content,
Err(err) => {
issues.push(issue(
AuditIssueKind::UnreadableFile,
&path,
1,
format!("failed to read documentation file: {err}"),
));
continue;
}
};
issues.extend(audit_fences(&path, &content));
issues.extend(audit_includes(&path, &content));
}
match discover_includes(&[docs_dir.to_path_buf()], include_base_paths) {
Ok(references) => {
for reference in references
.into_iter()
.filter(|reference| !is_excluded(&reference.source, exclude))
{
if !reference.target.exists() {
issues.push(issue(
AuditIssueKind::MissingInclude,
&reference.source,
reference.line,
format!("included snippet does not exist: {}", reference.target.display()),
));
}
}
}
Err(err) => issues.push(issue(
AuditIssueKind::UnreadableFile,
docs_dir,
1,
format!("failed to discover include references: {err}"),
)),
}
issues
}
fn audit_includes(path: &Path, content: &str) -> Vec<AuditIssue> {
content
.lines()
.enumerate()
.filter(|(_, line)| line.contains("--8<--") && parse_include_target(line).is_none())
.map(|(index, _)| {
issue(
AuditIssueKind::InvalidInclude,
path,
index + 1,
"invalid MkDocs include syntax, expected --8<-- \"path\"".to_string(),
)
})
.collect()
}
fn audit_fences(path: &Path, content: &str) -> Vec<AuditIssue> {
let mut issues = Vec::new();
let mut open: Option<(usize, String)> = None;
for (index, line) in content.lines().enumerate() {
let trimmed = line.trim();
let Some(rest) = trimmed.strip_prefix("```") else {
continue;
};
if rest.starts_with('`') {
continue;
}
if open.is_some() && (rest.is_empty() || rest.chars().all(|ch| ch == '`')) {
open = None;
continue;
}
if open.is_none() {
let tag = rest.split_whitespace().next().unwrap_or_default().to_string();
if tag.is_empty() {
issues.push(issue(
AuditIssueKind::UnknownLanguage,
path,
index + 1,
"fenced code block is missing a language tag".to_string(),
));
} else if Language::from_fence_tag(&tag) == Language::Unknown && !is_known_display_tag(&tag) {
issues.push(issue(
AuditIssueKind::UnknownLanguage,
path,
index + 1,
format!("unknown fenced code language: {tag}"),
));
}
open = Some((index + 1, tag));
}
}
if let Some((line, _)) = open {
issues.push(issue(
AuditIssueKind::BrokenFence,
path,
line,
"fenced code block is missing a closing fence".to_string(),
));
}
issues
}
fn markdown_files(base: &Path, exclude: &[PathBuf]) -> Vec<PathBuf> {
if !base.exists() {
return Vec::new();
}
let mut files: Vec<PathBuf> = WalkDir::new(base)
.follow_links(true)
.into_iter()
.filter_map(std::result::Result::ok)
.filter(|entry| entry.file_type().is_file())
.map(walkdir::DirEntry::into_path)
.filter(|path| !is_excluded(path, exclude))
.filter(|path| {
path.extension()
.and_then(|extension| extension.to_str())
.map(|extension| matches!(extension.to_lowercase().as_str(), "md" | "markdown" | "mdx"))
.unwrap_or(false)
})
.collect();
files.sort();
files
}
fn is_excluded(path: &Path, exclude: &[PathBuf]) -> bool {
exclude.iter().any(|excluded| path.starts_with(excluded))
}
fn issue(kind: AuditIssueKind, path: &Path, line: usize, message: String) -> AuditIssue {
AuditIssue {
severity: severity_for(&kind),
kind,
path: path.to_path_buf(),
line,
message,
}
}
fn severity_for(kind: &AuditIssueKind) -> AuditSeverity {
match kind {
AuditIssueKind::UnaccountedSnippet => AuditSeverity::Warning,
AuditIssueKind::BrokenFrontmatter
| AuditIssueKind::MissingFrontmatter
| AuditIssueKind::BrokenFence
| AuditIssueKind::MissingInclude
| AuditIssueKind::InvalidInclude
| AuditIssueKind::UnknownLanguage
| AuditIssueKind::UnreadableFile
| AuditIssueKind::MissingDirectory
| AuditIssueKind::CuratedGeneratedSnippet => AuditSeverity::Error,
}
}
fn is_known_display_tag(tag: &str) -> bool {
matches!(
tag.trim().to_lowercase().as_str(),
"json"
| "yaml"
| "yml"
| "xml"
| "ini"
| "csv"
| "tsv"
| "properties"
| "env"
| "diff"
| "patch"
| "html"
| "css"
| "scss"
| "sass"
| "svg"
| "markdown"
| "md"
| "mdx"
| "rst"
| "tex"
| "latex"
| "mermaid"
| "plantuml"
| "graphviz"
| "dot"
| "d2"
| "groovy"
| "gradle"
| "make"
| "makefile"
| "cmake"
| "nginx"
| "apache"
| "text"
| "txt"
| "plain"
| "plaintext"
| "output"
| "log"
| "console"
| "sql"
| "graphql"
| "gql"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reports_missing_frontmatter_and_broken_fence() {
let dir = tempfile::tempdir().unwrap();
let snippets = dir.path().join("snippets");
std::fs::create_dir_all(&snippets).unwrap();
std::fs::write(snippets.join("example.md"), "```python\nprint('ok')\n").unwrap();
let report = audit(&AuditConfig {
docs_dirs: Vec::new(),
snippet_dirs: vec![snippets],
require_frontmatter: true,
..AuditConfig::default()
});
assert!(report.has_errors());
assert_eq!(report.issues.len(), 2);
assert!(
report
.issues
.iter()
.any(|issue| issue.kind == AuditIssueKind::MissingFrontmatter)
);
assert!(
report
.issues
.iter()
.any(|issue| issue.kind == AuditIssueKind::BrokenFence)
);
}
#[test]
fn reports_a_docs_directory_that_does_not_exist() {
let dir = tempfile::tempdir().expect("temporary directory");
let missing = dir.path().join("docs-never-created");
let report = audit(&AuditConfig {
docs_dirs: vec![missing.clone()],
snippet_dirs: Vec::new(),
require_frontmatter: false,
..AuditConfig::default()
});
assert!(
report.has_errors(),
"a documentation root that does not exist must fail the audit, not read as clean"
);
assert_eq!(
report.issues.len(),
1,
"exactly one issue is expected for one missing root: {:?}",
report.issues
);
assert_eq!(report.issues[0].kind, AuditIssueKind::MissingDirectory);
assert!(
report.issues[0].message.contains(&missing.display().to_string()),
"the issue must name the missing path so the misconfiguration is actionable: {}",
report.issues[0].message
);
}
#[test]
fn an_existing_empty_docs_directory_audits_clean() {
let dir = tempfile::tempdir().expect("temporary directory");
let docs = dir.path().join("docs");
std::fs::create_dir_all(&docs).expect("create empty docs directory");
let report = audit(&AuditConfig {
docs_dirs: vec![docs],
snippet_dirs: Vec::new(),
require_frontmatter: false,
..AuditConfig::default()
});
assert_eq!(
report.issues,
Vec::new(),
"an existing but empty documentation root is not a misconfiguration"
);
}
#[test]
fn reports_invalid_and_missing_includes() {
let dir = tempfile::tempdir().unwrap();
let docs = dir.path().join("docs");
std::fs::create_dir_all(&docs).unwrap();
std::fs::write(
docs.join("index.md"),
"--8<-- snippets/python/example.md\n--8<-- \"snippets/python/missing.md\"\n",
)
.unwrap();
let report = audit(&AuditConfig {
docs_dirs: vec![docs],
snippet_dirs: Vec::new(),
require_frontmatter: false,
..AuditConfig::default()
});
assert_eq!(report.issues.len(), 2);
assert!(
report
.issues
.iter()
.any(|issue| issue.kind == AuditIssueKind::InvalidInclude)
);
assert!(
report
.issues
.iter()
.any(|issue| issue.kind == AuditIssueKind::MissingInclude)
);
}
#[test]
fn audits_fences_in_mdx_docs_pages() {
let dir = tempfile::tempdir().unwrap();
let docs = dir.path().join("docs");
std::fs::create_dir_all(&docs).unwrap();
std::fs::write(docs.join("usage.mdx"), "```python\nprint('ok')\n").unwrap();
let report = audit(&AuditConfig {
docs_dirs: vec![docs],
snippet_dirs: Vec::new(),
require_frontmatter: false,
..AuditConfig::default()
});
assert_eq!(report.issues.len(), 1);
assert_eq!(report.issues[0].kind, AuditIssueKind::BrokenFence);
}
}