Skip to main content

drft/
discovery.rs

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