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/// Respects `.gitignore` automatically. Returns paths relative to `root`, sorted.
10pub fn discover(
11    root: &Path,
12    include_patterns: &[String],
13    exclude_patterns: &[String],
14) -> Result<Vec<String>> {
15    let include_set = compile_globs(include_patterns)?.unwrap_or_else(GlobSet::empty);
16    let exclude_set = compile_globs(exclude_patterns)?;
17
18    let mut files = Vec::new();
19
20    let walker = WalkBuilder::new(root)
21        .follow_links(true)
22        .sort_by_file_name(|a, b| a.cmp(b))
23        .build();
24
25    for entry in walker {
26        let entry = entry?;
27        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
28            continue;
29        }
30        let path = entry.path();
31
32        let relative = path
33            .strip_prefix(root)
34            .expect("path should be under root")
35            .to_string_lossy()
36            .replace('\\', "/");
37
38        // Must match at least one include pattern
39        if !include_set.is_match(&relative) {
40            continue;
41        }
42
43        // Must not match any exclude pattern
44        if let Some(ref set) = exclude_set
45            && set.is_match(&relative)
46        {
47            continue;
48        }
49
50        files.push(relative);
51    }
52
53    // Literal include paths (no glob characters) may live inside gitignored
54    // directories. The walker respects gitignore and won't enter those dirs,
55    // so we check for them explicitly as a fallback.
56    for pattern in include_patterns {
57        if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
58            continue;
59        }
60        if files.iter().any(|f| f == pattern) {
61            continue;
62        }
63        let path = root.join(pattern);
64        if path.is_file() {
65            files.push(pattern.to_string());
66        }
67    }
68
69    files.sort();
70    Ok(files)
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use std::fs;
77    use tempfile::TempDir;
78
79    #[test]
80    fn discovers_files_matching_include() {
81        let dir = TempDir::new().unwrap();
82        fs::write(dir.path().join("index.md"), "# Hello").unwrap();
83        fs::write(dir.path().join("setup.md"), "# Setup").unwrap();
84        fs::write(dir.path().join("notes.txt"), "not markdown").unwrap();
85
86        // Only .md files
87        let files = discover(dir.path(), &["*.md".to_string()], &[]).unwrap();
88        assert_eq!(files, vec!["index.md", "setup.md"]);
89
90        // All files
91        let files = discover(dir.path(), &["*".to_string()], &[]).unwrap();
92        assert_eq!(files, vec!["index.md", "notes.txt", "setup.md"]);
93    }
94
95    #[test]
96    fn discovers_through_nested_drft_toml() {
97        // Nested drft.toml files are ordinary files — discovery walks through them.
98        let dir = TempDir::new().unwrap();
99        fs::write(dir.path().join("index.md"), "# Root").unwrap();
100
101        let child = dir.path().join("child");
102        fs::create_dir(&child).unwrap();
103        fs::write(child.join("drft.toml"), "").unwrap();
104        fs::write(child.join("inner.md"), "# Inner").unwrap();
105
106        let files = discover(dir.path(), &["**/*.md".to_string()], &[]).unwrap();
107        assert_eq!(files, vec!["child/inner.md", "index.md"]);
108    }
109
110    #[test]
111    fn respects_exclude_patterns() {
112        let dir = TempDir::new().unwrap();
113        fs::write(dir.path().join("index.md"), "# Hello").unwrap();
114        let drafts = dir.path().join("drafts");
115        fs::create_dir(&drafts).unwrap();
116        fs::write(drafts.join("wip.md"), "# WIP").unwrap();
117
118        let files = discover(dir.path(), &["*.md".to_string()], &["drafts/*".to_string()]).unwrap();
119        assert_eq!(files, vec!["index.md"]);
120    }
121
122    #[test]
123    fn respects_gitignore() {
124        let dir = TempDir::new().unwrap();
125        // The ignore crate requires a .git dir to activate .gitignore
126        fs::create_dir(dir.path().join(".git")).unwrap();
127        fs::write(dir.path().join(".gitignore"), "vendor/\n").unwrap();
128        fs::write(dir.path().join("index.md"), "# Hello").unwrap();
129        let vendor = dir.path().join("vendor");
130        fs::create_dir(&vendor).unwrap();
131        fs::write(vendor.join("lib.md"), "# Vendored").unwrap();
132
133        let files = discover(dir.path(), &["*.md".to_string()], &[]).unwrap();
134        assert_eq!(files, vec!["index.md"]);
135    }
136
137    #[test]
138    fn multiple_include_patterns() {
139        let dir = TempDir::new().unwrap();
140        fs::write(dir.path().join("index.md"), "# Hello").unwrap();
141        fs::write(dir.path().join("config.yaml"), "key: val").unwrap();
142        fs::write(dir.path().join("notes.txt"), "text").unwrap();
143
144        let files = discover(dir.path(), &["*.md".to_string(), "*.yaml".to_string()], &[]).unwrap();
145        assert_eq!(files, vec!["config.yaml", "index.md"]);
146    }
147}