1use anyhow::Result;
2use globset::{Glob, GlobSetBuilder};
3use ignore::WalkBuilder;
4use std::path::Path;
5
6pub 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 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
68pub 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 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; }
95 if entry.path() == root_owned {
96 return true;
97 }
98 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 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 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 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 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 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}