Skip to main content

markdown_contract/runner/
native.rs

1//! The `std::fs` layer over the runner core (the `native` feature, on by default; build
2//! with `default-features = false` for wasm32): the directory walker and the
3//! run-a-directory convenience, mirroring the TS `walkSync` and `runCorpus`.
4
5use std::borrow::Cow;
6use std::io;
7use std::path::{Path, PathBuf};
8
9use super::corpus::{CorpusConfig, GlobError, RunOptions, RunOutcome, run_lazy};
10
11/// Directories the walk never enters, as in the TS `walkSync`.
12const SKIPPED_DIRS: &[&str] = &[".git", "node_modules"];
13
14/// What a directory run can fail on.
15#[derive(Debug, thiserror::Error)]
16pub enum NativeRunError {
17    /// Listing a directory failed.
18    #[error("failed to read directory '{}': {source}", path.display())]
19    Walk {
20        /// the directory
21        path: PathBuf,
22        /// the I/O error
23        source: io::Error,
24    },
25    /// Reading a routed file failed.
26    #[error("failed to read '{}': {source}", path.display())]
27    Read {
28        /// the file
29        path: PathBuf,
30        /// the I/O error
31        source: io::Error,
32    },
33    /// A glob did not compile.
34    #[error(transparent)]
35    Glob(#[from] GlobError),
36}
37
38/// Every file under `root`, as paths relative to `root` with `/` separators. Directory
39/// entries are sorted by name (in UTF-16 order, as JS compares strings) before
40/// recursion, so the order matches the TS `walkSync`. `.git` and `node_modules` are
41/// skipped.
42pub 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
77/// Walk `root` and run the config over its files, reading only the files a rule
78/// routes. Invalid UTF-8 is replaced with U+FFFD, as Node's `utf8` read does.
79pub 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    // The walk is sorted per directory, yields posix-relative paths, and skips `.git`
127    // and `node_modules`.
128    #[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    // U+1F600 is the surrogate pair D83D DE00 in UTF-16, which sorts below U+FF5E;
148    // UTF-8 and `char` order put U+FF5E first.
149    #[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    // A symlink is neither followed nor listed, so a link back to the root cannot loop.
160    #[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    // A file no rule routes is never read, so a binary file beside the documents is
185    // fine; a routed file with invalid UTF-8 is read lossily.
186    #[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}