rac_engine/walk.rs
1//! Corpus file discovery — a byte-exact port of `find_markdown_files`
2//! (`src/rac/core/fs.py`) and the walk seam, per PORT-CONTRACT.d/09 §1.
3//!
4//! Landmines reproduced here:
5//! - Extension filter is the literal glob `*.md`, **case-sensitive** on Linux:
6//! `upper.MD`, `x.Md`, `x.markdown` do not match.
7//! - Hidden exclusion: any component of the path **relative to root** that
8//! starts with `.` drops the path (hidden dirs at any depth, and hidden
9//! files). Equivalent to pruning hidden entries during the walk.
10//! - Symlink asymmetry (Python 3.11 `rglob`): a symlinked **file** matching
11//! `*.md` IS yielded; a symlinked **directory** is NOT descended.
12//! - Sort is **component-wise** (`PurePath._cparts` tuple), NOT whole-string:
13//! `sub/c.md` sorts before `sub-x.md`. We sort by the tuple of relative
14//! components, each compared by Unicode scalar (== UTF-8 byte order).
15
16use std::path::{Path, PathBuf};
17use rayon::prelude::*;
18
19/// One discovered markdown file.
20#[derive(Debug, Clone)]
21pub struct WalkEntry {
22 /// Relative path components (relative to the walk root), in order.
23 pub components: Vec<String>,
24 /// Absolute path on disk, for actual file access.
25 pub abs: PathBuf,
26 /// `str(path)` as the oracle emits it downstream: the normalized root arg
27 /// prefix (PORT-CONTRACT.d/09 §1.6) joined with the relative components.
28 pub display: String,
29}
30
31impl WalkEntry {
32 /// The relative path, `/`-joined — matches `str(p.relative_to(root))`.
33 pub fn rel(&self) -> String {
34 self.components.join("/")
35 }
36}
37
38/// Find `*.md` files under `directory`, dropping any path with a dotted
39/// component, in component-wise sorted order. `recursive=false` looks only at
40/// direct children (`root.glob` instead of `root.rglob`).
41pub fn find_markdown_files(directory: &str, recursive: bool) -> Vec<WalkEntry> {
42 let root = Path::new(directory);
43 let mut found: Vec<(Vec<String>, PathBuf)> = if recursive {
44 collect_root_parallel(root)
45 } else {
46 let mut found = Vec::new();
47 collect(root, &mut Vec::new(), false, &mut found);
48 found
49 };
50
51 // Component-wise sort: compare the tuple of relative components. Rust's
52 // `Vec<String>` Ord is lexicographic, and `String` Ord is UTF-8 byte order
53 // which equals Unicode scalar order — exactly Python's `_cparts` compare.
54 found.sort_by(|a, b| a.0.cmp(&b.0));
55
56 let prefix = normalize_root(directory);
57 found
58 .into_iter()
59 .map(|(components, abs)| {
60 let display = join_display(&prefix, &components);
61 WalkEntry {
62 components,
63 abs,
64 display,
65 }
66 })
67 .collect()
68}
69
70/// Split a recursive walk across the root's immediate children. Results are
71/// sorted after collection, so scheduling cannot affect the oracle-visible
72/// component order. Deeper recursion stays serial within each shard, avoiding
73/// task creation per directory.
74fn collect_root_parallel(root: &Path) -> Vec<(Vec<String>, PathBuf)> {
75 let entries = match std::fs::read_dir(root) {
76 Ok(entries) => entries,
77 Err(_) => return Vec::new(),
78 };
79 let roots: Vec<(String, PathBuf, bool, bool)> = entries
80 .flatten()
81 .filter_map(|entry| {
82 let name = entry.file_name().into_string().ok()?;
83 if name.starts_with('.') {
84 return None;
85 }
86 let file_type = entry.file_type().ok();
87 Some((
88 name,
89 entry.path(),
90 file_type.as_ref().is_some_and(|kind| kind.is_dir()),
91 file_type.as_ref().is_some_and(|kind| kind.is_symlink()),
92 ))
93 })
94 .collect();
95 roots
96 .into_par_iter()
97 .map(|(name, path, is_dir, is_symlink)| {
98 let mut local = Vec::new();
99 if name.ends_with(".md") {
100 local.push((vec![name.clone()], path.clone()));
101 }
102 if is_dir && !is_symlink {
103 let mut rel = vec![name];
104 collect(&path, &mut rel, true, &mut local);
105 }
106 local
107 })
108 .flatten()
109 .collect()
110}
111
112/// Recursive directory walk. `rel` is the component stack from the root to
113/// `dir`. Hidden entries (name starting with `.`) are pruned wholesale, which
114/// is equivalent to the oracle's post-hoc "any relative part starts with `.`"
115/// filter (nothing under a hidden dir would survive it).
116fn collect(
117 dir: &Path,
118 rel: &mut Vec<String>,
119 recursive: bool,
120 out: &mut Vec<(Vec<String>, PathBuf)>,
121) {
122 let entries = match std::fs::read_dir(dir) {
123 Ok(e) => e,
124 Err(_) => return,
125 };
126 for entry in entries.flatten() {
127 let name = match entry.file_name().into_string() {
128 Ok(n) => n,
129 Err(_) => continue, // non-UTF-8 name: out of corpus scope
130 };
131 if name.starts_with('.') {
132 continue; // hidden component -> excluded (dirs and files)
133 }
134 // `file_type()` on Unix comes from the directory entry (lstat), so a
135 // symlink reports `is_symlink()`, NOT `is_dir()` — matching Python
136 // 3.11 rglob, which descends only real (non-symlink) directories.
137 let ft = entry.file_type();
138 let is_symlink = ft.as_ref().map(|t| t.is_symlink()).unwrap_or(false);
139 let is_dir = ft.as_ref().map(|t| t.is_dir()).unwrap_or(false);
140
141 // `*.md` name match — case-sensitive. `rglob("*.md")` globs the name
142 // regardless of entry type, so symlinked files (and, as an edge, dirs
143 // named `*.md`) are yielded.
144 if name.ends_with(".md") {
145 rel.push(name.clone());
146 out.push((rel.clone(), entry.path()));
147 rel.pop();
148 }
149
150 if recursive && is_dir && !is_symlink {
151 rel.push(name);
152 collect(&entry.path(), rel, recursive, out);
153 rel.pop();
154 }
155 }
156}
157
158/// Build `str(root / rel)` — the normalized root prefix joined to the relative
159/// components with `/`. Mirrors `str(path)` for paths from `rglob`.
160fn join_display(prefix: &str, components: &[String]) -> String {
161 if prefix.is_empty() || prefix == "." {
162 // pathlib drops a bare-`.` root when joining: Path('.')/'a.md' ->
163 // PosixPath('a.md'), so walked paths under `.` carry no prefix.
164 components.join("/")
165 } else if prefix == "/" {
166 // Absolute root "/": avoid a doubled leading slash.
167 format!("/{}", components.join("/"))
168 } else if prefix.ends_with('/') {
169 // Only a preserved "//" prefix ends with a slash here.
170 format!("{}{}", prefix, components.join("/"))
171 } else {
172 format!("{}/{}", prefix, components.join("/"))
173 }
174}
175
176/// Normalize a directory argument the way `str(Path(directory))` does
177/// (PurePosixPath semantics, PORT-CONTRACT.d/09 §1.6):
178/// - trailing slashes stripped (`decisions/` -> `decided`)
179/// - leading `./` stripped (`./decisions/` -> `decided`)
180/// - repeated slashes collapsed (`decisions//` -> `decided`), interior `.` removed
181/// (`decisions/./x` -> `decisions/x`)
182/// - `..` preserved; absolute stays absolute
183/// - the empty / `.` argument normalizes to `.`
184pub fn normalize_root(directory: &str) -> String {
185 // Leading-slash handling matches PurePosixPath: exactly two leading
186 // slashes are preserved as "//", one or three-plus collapse to "/".
187 let leading = directory.chars().take_while(|&c| c == '/').count();
188 let root_prefix = match leading {
189 0 => "",
190 2 => "//",
191 _ => "/",
192 };
193
194 let parts: Vec<&str> = directory
195 .split('/')
196 .filter(|p| !p.is_empty() && *p != ".")
197 .collect();
198
199 if root_prefix.is_empty() {
200 if parts.is_empty() {
201 ".".to_string()
202 } else {
203 parts.join("/")
204 }
205 } else if root_prefix == "//" {
206 format!("//{}", parts.join("/"))
207 } else {
208 format!("/{}", parts.join("/"))
209 }
210}
211
212/// `str(Path(root) / a / b / ...)` — the normalized root joined with literal
213/// relative components (skill/hook install destination paths). Reuses the
214/// walk's `join_display` semantics: a bare-`.` root vanishes, "/" and a
215/// preserved "//" prefix avoid doubled slashes.
216pub fn py_join(root: &str, components: &[&str]) -> String {
217 let owned: Vec<String> = components.iter().map(|c| (*c).to_string()).collect();
218 join_display(&normalize_root(root), &owned)
219}
220
221/// What a command should do with a positional path argument: validate/inspect
222/// and friends dispatch a single file directly, a directory through the walk.
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub enum WalkTarget {
225 /// The argument names one file — process it directly.
226 File(PathBuf),
227 /// The argument names a directory — walk it.
228 Directory(PathBuf),
229 /// The argument is neither (missing / special) — the caller raises the
230 /// command's own usage error.
231 Missing(PathBuf),
232}
233
234/// Classify a positional path argument for single-file vs directory dispatch.
235pub fn dispatch(path: &str) -> WalkTarget {
236 let p = PathBuf::from(path);
237 if p.is_file() {
238 WalkTarget::File(p)
239 } else if p.is_dir() {
240 WalkTarget::Directory(p)
241 } else {
242 WalkTarget::Missing(p)
243 }
244}
245
246/// True if `path` is a directory (the guard `Path(arg).is_dir()` used by
247/// `stats`/`export`/`review` before walking).
248pub fn is_directory(path: &str) -> bool {
249 Path::new(path).is_dir()
250}