markdown_contract/runner/
native.rs1use std::borrow::Cow;
6use std::io;
7use std::path::{Path, PathBuf};
8
9use super::corpus::{CorpusConfig, GlobError, RunOptions, RunOutcome, run_lazy};
10
11const SKIPPED_DIRS: &[&str] = &[".git", "node_modules"];
13
14#[derive(Debug, thiserror::Error)]
16pub enum NativeRunError {
17 #[error("failed to read directory '{}': {source}", path.display())]
19 Walk {
20 path: PathBuf,
22 source: io::Error,
24 },
25 #[error("failed to read '{}': {source}", path.display())]
27 Read {
28 path: PathBuf,
30 source: io::Error,
32 },
33 #[error(transparent)]
35 Glob(#[from] GlobError),
36}
37
38pub fn walk_dir(root: &Path) -> Result<Vec<String>, NativeRunError> {
43 fn recur(abs: &Path, rel: &str, out: &mut Vec<String>) -> Result<(), NativeRunError> {
44 let walk_err = |source| NativeRunError::Walk {
45 path: abs.to_path_buf(),
46 source,
47 };
48 let mut entries = std::fs::read_dir(abs)
49 .and_then(|dir| dir.collect::<io::Result<Vec<_>>>())
50 .map_err(walk_err)?
51 .into_iter()
52 .map(|e| (e.file_name().to_string_lossy().into_owned(), e))
53 .collect::<Vec<_>>();
54 entries.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
55 for (name, entry) in entries {
56 let child_rel = if rel.is_empty() {
57 name.clone()
58 } else {
59 format!("{rel}/{name}")
60 };
61 let file_type = entry.file_type().map_err(walk_err)?;
62 if file_type.is_dir() {
63 if !SKIPPED_DIRS.contains(&name.as_str()) {
64 recur(&entry.path(), &child_rel, out)?;
65 }
66 } else if file_type.is_file() {
67 out.push(child_rel);
68 }
69 }
70 Ok(())
71 }
72 let mut out = Vec::new();
73 recur(root, "", &mut out)?;
74 Ok(out)
75}
76
77pub fn run_corpus_dir(
80 config: &CorpusConfig,
81 root: &Path,
82 opts: &RunOptions,
83) -> Result<RunOutcome, NativeRunError> {
84 let paths = walk_dir(root)?;
85 let files = paths.iter().map(|rel| {
86 let rel = rel.as_str();
87 let read = move || {
88 let path = root.join(rel);
89 std::fs::read(&path)
90 .map(|bytes| Cow::Owned(String::from_utf8_lossy(&bytes).into_owned()))
91 .map_err(|source| NativeRunError::Read { path, source })
92 };
93 (rel, read)
94 });
95 run_lazy(config, files, opts)
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101 use crate::contract::{Contract, LevelOpts, section, sections};
102 use crate::runner::corpus::CorpusRule;
103
104 fn scratch(name: &str) -> std::path::PathBuf {
105 let dir = std::env::temp_dir().join(format!(
106 "markdown-contract-native-{name}-{}",
107 std::process::id()
108 ));
109 let _ = std::fs::remove_dir_all(&dir);
110 std::fs::create_dir_all(&dir).unwrap();
111 dir
112 }
113
114 fn md_rule() -> CorpusConfig {
115 CorpusConfig {
116 rules: vec![CorpusRule {
117 include: vec!["**/*.md".into()],
118 exclude: vec![],
119 contract: Contract::new()
120 .body(sections(LevelOpts::default(), vec![section("Overview")])),
121 name: None,
122 }],
123 }
124 }
125
126 #[test]
129 fn walk_is_deterministic_and_relative() {
130 let dir = scratch("walk");
131 for d in ["b", ".git", "node_modules/x"] {
132 std::fs::create_dir_all(dir.join(d)).unwrap();
133 }
134 for f in [
135 "b/inner.md",
136 "a.md",
137 "c.md",
138 ".git/HEAD",
139 "node_modules/x/r.md",
140 ] {
141 std::fs::write(dir.join(f), "x").unwrap();
142 }
143 assert_eq!(walk_dir(&dir).unwrap(), vec!["a.md", "b/inner.md", "c.md"]);
144 std::fs::remove_dir_all(&dir).unwrap();
145 }
146
147 #[test]
150 fn walk_sorts_names_in_utf16_order() {
151 let dir = scratch("utf16");
152 for f in ["\u{FF5E}.md", "\u{1F600}.md"] {
153 std::fs::write(dir.join(f), "x").unwrap();
154 }
155 assert_eq!(walk_dir(&dir).unwrap(), vec!["\u{1F600}.md", "\u{FF5E}.md"]);
156 std::fs::remove_dir_all(&dir).unwrap();
157 }
158
159 #[cfg(unix)]
161 #[test]
162 fn walk_skips_symlinks() {
163 let dir = scratch("symlinks");
164 std::fs::write(dir.join("real.md"), "x").unwrap();
165 std::os::unix::fs::symlink(dir.join("real.md"), dir.join("link.md")).unwrap();
166 std::os::unix::fs::symlink(&dir, dir.join("loop")).unwrap();
167 assert_eq!(walk_dir(&dir).unwrap(), vec!["real.md"]);
168 std::fs::remove_dir_all(&dir).unwrap();
169 }
170
171 #[test]
172 fn run_corpus_dir_reads_routes_and_reports() {
173 let dir = scratch("run");
174 std::fs::write(dir.join("good.md"), "## Overview\n\nfine\n").unwrap();
175 std::fs::write(dir.join("bad.md"), "## Wrong\n\nprose\n").unwrap();
176 let out = run_corpus_dir(&md_rule(), &dir, &RunOptions::default()).unwrap();
177 assert_eq!(out.exit_code, 1);
178 assert_eq!(out.stats.files_matched, 2);
179 assert_eq!(out.findings.len(), 1);
180 assert_eq!(out.findings[0].path, "bad.md");
181 std::fs::remove_dir_all(&dir).unwrap();
182 }
183
184 #[test]
187 fn only_routed_files_are_read_and_bad_utf8_is_replaced() {
188 let dir = scratch("lossy");
189 std::fs::write(dir.join("image.png"), [0x89, 0x50, 0xff, 0xfe]).unwrap();
190 std::fs::write(dir.join("doc.md"), b"## Overview\n\nbad \xff byte\n").unwrap();
191 let out = run_corpus_dir(&md_rule(), &dir, &RunOptions::default()).unwrap();
192 assert_eq!(out.stats.files_scanned, 2);
193 assert_eq!(out.stats.files_matched, 1);
194 assert_eq!(out.findings, vec![]);
195 std::fs::remove_dir_all(&dir).unwrap();
196 }
197
198 #[test]
199 fn a_missing_root_names_the_directory() {
200 let missing = std::env::temp_dir().join("markdown-contract-native-missing-dir");
201 let err = run_corpus_dir(&md_rule(), &missing, &RunOptions::default()).unwrap_err();
202 assert!(matches!(err, NativeRunError::Walk { .. }));
203 assert!(
204 err.to_string()
205 .contains("markdown-contract-native-missing-dir")
206 );
207 }
208}