Skip to main content

drft/
discovery.rs

1use anyhow::Result;
2use globset::GlobSet;
3use ignore::WalkBuilder;
4use std::path::Path;
5
6use crate::config::compile_globs;
7
8/// Discover files under `root` matching `include` patterns (minus `exclude`),
9/// stopping at child graph boundaries (directories containing `drft.toml`).
10/// Respects `.gitignore` automatically. Returns paths relative to `root`, sorted.
11pub fn discover(
12    root: &Path,
13    include_patterns: &[String],
14    exclude_patterns: &[String],
15) -> Result<Vec<String>> {
16    let include_set = compile_globs(include_patterns)?.unwrap_or_else(GlobSet::empty);
17    let exclude_set = compile_globs(exclude_patterns)?;
18
19    let mut files = Vec::new();
20    let root_owned = root.to_path_buf();
21
22    let walker = WalkBuilder::new(root)
23        .follow_links(true)
24        .sort_by_file_name(|a, b| a.cmp(b))
25        .filter_entry(move |entry| {
26            if entry.file_type().is_some_and(|ft| ft.is_dir()) {
27                if entry.path() == root_owned {
28                    return true;
29                }
30                // Stop at child graph boundaries
31                if entry.path().join("drft.toml").exists() {
32                    return false;
33                }
34            }
35            true
36        })
37        .build();
38
39    for entry in walker {
40        let entry = entry?;
41        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
42            continue;
43        }
44        let path = entry.path();
45
46        let relative = path
47            .strip_prefix(root)
48            .expect("path should be under root")
49            .to_string_lossy()
50            .replace('\\', "/");
51
52        // Must match at least one include pattern
53        if !include_set.is_match(&relative) {
54            continue;
55        }
56
57        // Must not match any exclude pattern
58        if let Some(ref set) = exclude_set
59            && set.is_match(&relative)
60        {
61            continue;
62        }
63
64        files.push(relative);
65    }
66
67    files.sort();
68    Ok(files)
69}
70
71/// Find child graph directories (those containing `drft.toml`) under `root`.
72/// Returns relative paths without trailing slash (e.g., `"research"`), sorted.
73/// Only returns the shallowest boundary — does not recurse past them.
74/// Respects `.gitignore` and `exclude_patterns` from config.
75pub fn find_child_graphs(root: &Path, exclude_patterns: &[String]) -> Result<Vec<String>> {
76    let ignore_set = compile_globs(exclude_patterns)?;
77
78    let mut child_graphs = Vec::new();
79    let root_owned = root.to_path_buf();
80
81    // Use the ignore crate to respect .gitignore, and stop recursing
82    // past child graph boundaries.
83    let walker = WalkBuilder::new(root)
84        .follow_links(true)
85        .sort_by_file_name(|a, b| a.cmp(b))
86        .filter_entry(move |entry| {
87            if !entry.file_type().is_some_and(|ft| ft.is_dir()) {
88                return false; // skip files, we only care about directories
89            }
90            if entry.path() == root_owned {
91                return true;
92            }
93            // Allow entry so we can inspect it, but we'll track boundaries below
94            true
95        })
96        .build();
97
98    let mut found_prefixes: Vec<String> = Vec::new();
99
100    for entry in walker.filter_map(|e| e.ok()) {
101        if !entry.file_type().is_some_and(|ft| ft.is_dir()) {
102            continue;
103        }
104        if entry.path() == root {
105            continue;
106        }
107
108        let relative = entry
109            .path()
110            .strip_prefix(root)
111            .expect("path should be under root")
112            .to_string_lossy()
113            .replace('\\', "/");
114
115        // Skip if inside an already-found child graph
116        let inside_existing = found_prefixes
117            .iter()
118            .any(|s| relative == s.as_str() || relative.starts_with(&format!("{s}/")));
119        if inside_existing {
120            continue;
121        }
122
123        if entry.path().join("drft.toml").exists() {
124            // Skip if matched by ignore patterns
125            if let Some(ref set) = ignore_set
126                && set.is_match(&relative)
127            {
128                continue;
129            }
130
131            found_prefixes.push(relative.clone());
132            child_graphs.push(relative);
133        }
134    }
135
136    child_graphs.sort();
137    Ok(child_graphs)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use std::fs;
144    use tempfile::TempDir;
145
146    #[test]
147    fn discovers_files_matching_include() {
148        let dir = TempDir::new().unwrap();
149        fs::write(dir.path().join("index.md"), "# Hello").unwrap();
150        fs::write(dir.path().join("setup.md"), "# Setup").unwrap();
151        fs::write(dir.path().join("notes.txt"), "not markdown").unwrap();
152
153        // Only .md files
154        let files = discover(dir.path(), &["*.md".to_string()], &[]).unwrap();
155        assert_eq!(files, vec!["index.md", "setup.md"]);
156
157        // All files
158        let files = discover(dir.path(), &["*".to_string()], &[]).unwrap();
159        assert_eq!(files, vec!["index.md", "notes.txt", "setup.md"]);
160    }
161
162    #[test]
163    fn stops_at_graph_boundary() {
164        let dir = TempDir::new().unwrap();
165        fs::write(dir.path().join("index.md"), "# Root").unwrap();
166
167        let child = dir.path().join("child");
168        fs::create_dir(&child).unwrap();
169        fs::write(child.join("drft.toml"), "").unwrap();
170        fs::write(child.join("inner.md"), "# Inner").unwrap();
171
172        let files = discover(dir.path(), &["*.md".to_string()], &[]).unwrap();
173        assert_eq!(files, vec!["index.md"]);
174    }
175
176    #[test]
177    fn respects_exclude_patterns() {
178        let dir = TempDir::new().unwrap();
179        fs::write(dir.path().join("index.md"), "# Hello").unwrap();
180        let drafts = dir.path().join("drafts");
181        fs::create_dir(&drafts).unwrap();
182        fs::write(drafts.join("wip.md"), "# WIP").unwrap();
183
184        let files = discover(dir.path(), &["*.md".to_string()], &["drafts/*".to_string()]).unwrap();
185        assert_eq!(files, vec!["index.md"]);
186    }
187
188    #[test]
189    fn respects_gitignore() {
190        let dir = TempDir::new().unwrap();
191        // The ignore crate requires a .git dir to activate .gitignore
192        fs::create_dir(dir.path().join(".git")).unwrap();
193        fs::write(dir.path().join(".gitignore"), "vendor/\n").unwrap();
194        fs::write(dir.path().join("index.md"), "# Hello").unwrap();
195        let vendor = dir.path().join("vendor");
196        fs::create_dir(&vendor).unwrap();
197        fs::write(vendor.join("lib.md"), "# Vendored").unwrap();
198
199        let files = discover(dir.path(), &["*.md".to_string()], &[]).unwrap();
200        assert_eq!(files, vec!["index.md"]);
201    }
202
203    #[test]
204    fn multiple_include_patterns() {
205        let dir = TempDir::new().unwrap();
206        fs::write(dir.path().join("index.md"), "# Hello").unwrap();
207        fs::write(dir.path().join("config.yaml"), "key: val").unwrap();
208        fs::write(dir.path().join("notes.txt"), "text").unwrap();
209
210        let files = discover(dir.path(), &["*.md".to_string(), "*.yaml".to_string()], &[]).unwrap();
211        assert_eq!(files, vec!["config.yaml", "index.md"]);
212    }
213
214    #[test]
215    fn finds_child_graphs() {
216        let dir = TempDir::new().unwrap();
217        fs::write(dir.path().join("index.md"), "# Root").unwrap();
218
219        let alpha = dir.path().join("alpha");
220        fs::create_dir(&alpha).unwrap();
221        fs::write(alpha.join("drft.toml"), "").unwrap();
222
223        let beta = dir.path().join("beta");
224        fs::create_dir(&beta).unwrap();
225        fs::write(beta.join("drft.toml"), "").unwrap();
226
227        // No config in gamma — not a child graph
228        let gamma = dir.path().join("gamma");
229        fs::create_dir(&gamma).unwrap();
230        fs::write(gamma.join("readme.md"), "").unwrap();
231
232        let child_graphs = find_child_graphs(dir.path(), &[]).unwrap();
233        assert_eq!(child_graphs, vec!["alpha", "beta"]);
234    }
235
236    #[test]
237    fn child_graphs_stops_at_boundary() {
238        let dir = TempDir::new().unwrap();
239        let child = dir.path().join("child");
240        fs::create_dir(&child).unwrap();
241        fs::write(child.join("drft.toml"), "").unwrap();
242
243        // Grandchild graph — should NOT appear from parent's perspective
244        let grandchild = child.join("nested");
245        fs::create_dir(&grandchild).unwrap();
246        fs::write(grandchild.join("drft.toml"), "").unwrap();
247
248        let child_graphs = find_child_graphs(dir.path(), &[]).unwrap();
249        assert_eq!(child_graphs, vec!["child"]);
250    }
251}