Skip to main content

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}