context_builder/
file_utils.rs

1use ignore::{DirEntry, WalkBuilder};
2use std::fs;
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5
6/// Collects all files to be processed using `ignore` crate for efficient traversal.
7pub fn collect_files(
8    base_path: &Path,
9    filters: &[String],
10    ignores: &[String],
11) -> io::Result<Vec<DirEntry>> {
12    let mut ignores = ignores.to_vec();
13    ignores.push(".context-builder.toml".to_string());
14
15    let mut walker = WalkBuilder::new(base_path);
16    // By default, the "ignore" crate respects .gitignore and hidden files, so we don't need walker.hidden(false)
17
18    // Apply custom ignore filtering later during iteration since `add_ignore` expects file paths to ignore files, not patterns.
19
20    if !filters.is_empty() {
21        let mut type_builder = ignore::types::TypesBuilder::new();
22        type_builder.add_defaults();
23        for filter in filters {
24            let _ = type_builder.add(filter, &format!("*.{}", filter));
25            type_builder.select(filter);
26        }
27        let types = type_builder.build().unwrap();
28        walker.types(types);
29    }
30
31    Ok(walker
32        .build()
33        .filter_map(Result::ok)
34        .filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
35        .filter(|e| {
36            let path = e.path();
37            // Exclude any entry that contains an ignored directory or file name as a path component
38            !path.components().any(|c| {
39                let comp = c.as_os_str();
40                ignores
41                    .iter()
42                    .any(|name| comp == std::ffi::OsStr::new(name))
43            })
44        })
45        .collect())
46}
47
48/// Asks for user confirmation if the number of files is large.
49pub fn confirm_processing(file_count: usize) -> io::Result<bool> {
50    if file_count > 100 {
51        print!(
52            "Warning: You're about to process {} files. This might take a while. Continue? [y/N] ",
53            file_count
54        );
55        io::stdout().flush()?;
56        let mut input = String::new();
57        io::stdin().read_line(&mut input)?;
58        if !input.trim().eq_ignore_ascii_case("y") {
59            return Ok(false);
60        }
61    }
62    Ok(true)
63}
64
65/// Asks for user confirmation to overwrite an existing file.
66pub fn confirm_overwrite(file_path: &str) -> io::Result<bool> {
67    print!("The file '{}' already exists. Overwrite? [y/N] ", file_path);
68    io::stdout().flush()?;
69    let mut input = String::new();
70    io::stdin().read_line(&mut input)?;
71
72    if input.trim().eq_ignore_ascii_case("y") {
73        Ok(true)
74    } else {
75        Ok(false)
76    }
77}
78
79pub fn find_latest_file(dir: &Path) -> io::Result<Option<PathBuf>> {
80    if !dir.is_dir() {
81        return Ok(None);
82    }
83
84    let mut latest_file = None;
85    let mut latest_time = std::time::SystemTime::UNIX_EPOCH;
86
87    for entry in fs::read_dir(dir)? {
88        let entry = entry?;
89        let path = entry.path();
90        if path.is_file() {
91            let metadata = fs::metadata(&path)?;
92            let modified = metadata.modified()?;
93            if modified > latest_time {
94                latest_time = modified;
95                latest_file = Some(path);
96            }
97        }
98    }
99
100    Ok(latest_file)
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use std::fs;
107    use std::path::Path;
108    use tempfile::tempdir;
109
110    fn to_rel_paths(mut entries: Vec<DirEntry>, base: &Path) -> Vec<String> {
111        entries.sort_by_key(|e| e.path().to_path_buf());
112        entries
113            .iter()
114            .map(|e| {
115                e.path()
116                    .strip_prefix(base)
117                    .unwrap()
118                    .to_string_lossy()
119                    .replace('\\', "/")
120            })
121            .collect()
122    }
123
124    #[test]
125    fn collect_files_respects_filters() {
126        let dir = tempdir().unwrap();
127        let base = dir.path();
128
129        // create files
130        fs::create_dir_all(base.join("src")).unwrap();
131        fs::create_dir_all(base.join("scripts")).unwrap();
132        fs::write(base.join("src").join("main.rs"), "fn main() {}").unwrap();
133        fs::write(base.join("Cargo.toml"), "[package]\nname=\"x\"").unwrap();
134        fs::write(base.join("README.md"), "# readme").unwrap();
135        fs::write(base.join("scripts").join("build.sh"), "#!/bin/sh\n").unwrap();
136
137        let filters = vec!["rs".to_string(), "toml".to_string()];
138        let ignores: Vec<String> = vec![];
139
140        let files = collect_files(base, &filters, &ignores).unwrap();
141        let relative_paths = to_rel_paths(files, base);
142
143        assert!(relative_paths.contains(&"src/main.rs".to_string()));
144        assert!(relative_paths.contains(&"Cargo.toml".to_string()));
145        assert!(!relative_paths.contains(&"README.md".to_string()));
146        assert!(!relative_paths.contains(&"scripts/build.sh".to_string()));
147    }
148
149    #[test]
150    fn collect_files_respects_ignores_for_dirs_and_files() {
151        let dir = tempdir().unwrap();
152        let base = dir.path();
153
154        fs::create_dir_all(base.join("src")).unwrap();
155        fs::create_dir_all(base.join("target")).unwrap();
156        fs::create_dir_all(base.join("node_modules")).unwrap();
157
158        fs::write(base.join("src").join("main.rs"), "fn main() {}").unwrap();
159        fs::write(base.join("target").join("artifact.txt"), "bin").unwrap();
160        fs::write(base.join("node_modules").join("pkg.js"), "console.log();").unwrap();
161        fs::write(base.join("README.md"), "# readme").unwrap();
162
163        let filters: Vec<String> = vec![];
164        let ignores: Vec<String> = vec!["target".into(), "node_modules".into(), "README.md".into()];
165
166        let files = collect_files(base, &filters, &ignores).unwrap();
167        let relative_paths = to_rel_paths(files, base);
168
169        assert!(relative_paths.contains(&"src/main.rs".to_string()));
170        assert!(!relative_paths.contains(&"target/artifact.txt".to_string()));
171        assert!(!relative_paths.contains(&"node_modules/pkg.js".to_string()));
172        assert!(!relative_paths.contains(&"README.md".to_string()));
173    }
174}