use std::borrow::Cow;
use std::io;
use std::path::{Path, PathBuf};
use super::corpus::{CorpusConfig, GlobError, RunOptions, RunOutcome, run_lazy};
const SKIPPED_DIRS: &[&str] = &[".git", "node_modules"];
#[derive(Debug, thiserror::Error)]
pub enum NativeRunError {
#[error("failed to read directory '{}': {source}", path.display())]
Walk {
path: PathBuf,
source: io::Error,
},
#[error("failed to read '{}': {source}", path.display())]
Read {
path: PathBuf,
source: io::Error,
},
#[error(transparent)]
Glob(#[from] GlobError),
}
pub fn walk_dir(root: &Path) -> Result<Vec<String>, NativeRunError> {
fn recur(abs: &Path, rel: &str, out: &mut Vec<String>) -> Result<(), NativeRunError> {
let walk_err = |source| NativeRunError::Walk {
path: abs.to_path_buf(),
source,
};
let mut entries = std::fs::read_dir(abs)
.and_then(|dir| dir.collect::<io::Result<Vec<_>>>())
.map_err(walk_err)?
.into_iter()
.map(|e| (e.file_name().to_string_lossy().into_owned(), e))
.collect::<Vec<_>>();
entries.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
for (name, entry) in entries {
let child_rel = if rel.is_empty() {
name.clone()
} else {
format!("{rel}/{name}")
};
let file_type = entry.file_type().map_err(walk_err)?;
if file_type.is_dir() {
if !SKIPPED_DIRS.contains(&name.as_str()) {
recur(&entry.path(), &child_rel, out)?;
}
} else if file_type.is_file() {
out.push(child_rel);
}
}
Ok(())
}
let mut out = Vec::new();
recur(root, "", &mut out)?;
Ok(out)
}
pub fn run_corpus_dir(
config: &CorpusConfig,
root: &Path,
opts: &RunOptions,
) -> Result<RunOutcome, NativeRunError> {
let paths = walk_dir(root)?;
let files = paths.iter().map(|rel| {
let rel = rel.as_str();
let read = move || {
let path = root.join(rel);
std::fs::read(&path)
.map(|bytes| Cow::Owned(String::from_utf8_lossy(&bytes).into_owned()))
.map_err(|source| NativeRunError::Read { path, source })
};
(rel, read)
});
run_lazy(config, files, opts)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::contract::{Contract, LevelOpts, section, sections};
use crate::runner::corpus::CorpusRule;
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"markdown-contract-native-{name}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn md_rule() -> CorpusConfig {
CorpusConfig {
rules: vec![CorpusRule {
include: vec!["**/*.md".into()],
exclude: vec![],
contract: Contract::new()
.body(sections(LevelOpts::default(), vec![section("Overview")])),
name: None,
}],
}
}
#[test]
fn walk_is_deterministic_and_relative() {
let dir = scratch("walk");
for d in ["b", ".git", "node_modules/x"] {
std::fs::create_dir_all(dir.join(d)).unwrap();
}
for f in [
"b/inner.md",
"a.md",
"c.md",
".git/HEAD",
"node_modules/x/r.md",
] {
std::fs::write(dir.join(f), "x").unwrap();
}
assert_eq!(walk_dir(&dir).unwrap(), vec!["a.md", "b/inner.md", "c.md"]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn walk_sorts_names_in_utf16_order() {
let dir = scratch("utf16");
for f in ["\u{FF5E}.md", "\u{1F600}.md"] {
std::fs::write(dir.join(f), "x").unwrap();
}
assert_eq!(walk_dir(&dir).unwrap(), vec!["\u{1F600}.md", "\u{FF5E}.md"]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn walk_skips_symlinks() {
let dir = scratch("symlinks");
std::fs::write(dir.join("real.md"), "x").unwrap();
std::os::unix::fs::symlink(dir.join("real.md"), dir.join("link.md")).unwrap();
std::os::unix::fs::symlink(&dir, dir.join("loop")).unwrap();
assert_eq!(walk_dir(&dir).unwrap(), vec!["real.md"]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn run_corpus_dir_reads_routes_and_reports() {
let dir = scratch("run");
std::fs::write(dir.join("good.md"), "## Overview\n\nfine\n").unwrap();
std::fs::write(dir.join("bad.md"), "## Wrong\n\nprose\n").unwrap();
let out = run_corpus_dir(&md_rule(), &dir, &RunOptions::default()).unwrap();
assert_eq!(out.exit_code, 1);
assert_eq!(out.stats.files_matched, 2);
assert_eq!(out.findings.len(), 1);
assert_eq!(out.findings[0].path, "bad.md");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn only_routed_files_are_read_and_bad_utf8_is_replaced() {
let dir = scratch("lossy");
std::fs::write(dir.join("image.png"), [0x89, 0x50, 0xff, 0xfe]).unwrap();
std::fs::write(dir.join("doc.md"), b"## Overview\n\nbad \xff byte\n").unwrap();
let out = run_corpus_dir(&md_rule(), &dir, &RunOptions::default()).unwrap();
assert_eq!(out.stats.files_scanned, 2);
assert_eq!(out.stats.files_matched, 1);
assert_eq!(out.findings, vec![]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_missing_root_names_the_directory() {
let missing = std::env::temp_dir().join("markdown-contract-native-missing-dir");
let err = run_corpus_dir(&md_rule(), &missing, &RunOptions::default()).unwrap_err();
assert!(matches!(err, NativeRunError::Walk { .. }));
assert!(
err.to_string()
.contains("markdown-contract-native-missing-dir")
);
}
}