spec_drift/sources/
mod.rs1use crate::error::SpecDriftError;
2use ignore::WalkBuilder;
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7#[derive(Debug, Default, Clone)]
8pub struct DiscoveredFiles {
9 pub rust: Vec<PathBuf>,
10 pub markdown: Vec<PathBuf>,
11 pub yaml: Vec<PathBuf>,
12 pub makefiles: Vec<PathBuf>,
13}
14
15pub struct FsWalker;
16
17impl FsWalker {
18 pub fn walk(root: &Path) -> Result<DiscoveredFiles, SpecDriftError> {
20 let mut out = DiscoveredFiles::default();
21
22 for entry in WalkBuilder::new(root).hidden(false).build() {
23 let entry = entry?;
24 if !entry.file_type().is_some_and(|t| t.is_file()) {
25 continue;
26 }
27 let path = entry.into_path();
28
29 let file_name = path
30 .file_name()
31 .and_then(|n| n.to_str())
32 .unwrap_or_default();
33
34 if matches!(
36 file_name,
37 "Makefile" | "makefile" | "GNUmakefile" | "justfile"
38 ) {
39 out.makefiles.push(path);
40 continue;
41 }
42
43 match path.extension().and_then(|e| e.to_str()) {
44 Some("rs") => out.rust.push(path),
45 Some("md") | Some("markdown") => out.markdown.push(path),
46 Some("yaml") | Some("yml") => out.yaml.push(path),
47 Some("mk") => out.makefiles.push(path),
48 _ => {}
49 }
50 }
51
52 out.rust.sort();
53 out.markdown.sort();
54 out.yaml.sort();
55 out.makefiles.sort();
56 Ok(out)
57 }
58}
59
60pub struct GitHistory;
68
69impl GitHistory {
70 pub fn changed_files(root: &Path, reference: &str) -> Option<HashSet<PathBuf>> {
75 let out = Command::new("git")
76 .current_dir(root)
77 .args(["diff", "--name-only", reference])
78 .output()
79 .ok()?;
80 if !out.status.success() {
81 return None;
82 }
83 let mut set: HashSet<PathBuf> = String::from_utf8(out.stdout)
84 .ok()?
85 .lines()
86 .map(|l| l.trim())
87 .filter(|l| !l.is_empty())
88 .map(|l| root.join(l))
89 .collect();
90
91 let out = Command::new("git")
92 .current_dir(root)
93 .args(["ls-files", "--others", "--exclude-standard"])
94 .output()
95 .ok()?;
96 if !out.status.success() {
97 return None;
98 }
99 for path in String::from_utf8(out.stdout).ok()?.lines() {
100 let path = path.trim();
101 if !path.is_empty() {
102 set.insert(root.join(path));
103 }
104 }
105
106 Some(set)
107 }
108
109 pub fn narrow(files: DiscoveredFiles, changed: &HashSet<PathBuf>) -> DiscoveredFiles {
112 fn keep(v: Vec<PathBuf>, changed: &HashSet<PathBuf>) -> Vec<PathBuf> {
113 let mut v: Vec<PathBuf> = v.into_iter().filter(|p| changed.contains(p)).collect();
114 v.sort();
115 v
116 }
117 DiscoveredFiles {
118 rust: keep(files.rust, changed),
119 markdown: keep(files.markdown, changed),
120 yaml: keep(files.yaml, changed),
121 makefiles: keep(files.makefiles, changed),
122 }
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use std::fs;
130
131 #[test]
132 fn git_history_narrow_keeps_only_changed_paths() {
133 let mut files = DiscoveredFiles::default();
134 files.rust.push(PathBuf::from("/root/a.rs"));
135 files.rust.push(PathBuf::from("/root/b.rs"));
136 files.markdown.push(PathBuf::from("/root/README.md"));
137
138 let mut changed = HashSet::new();
139 changed.insert(PathBuf::from("/root/b.rs"));
140
141 let narrowed = GitHistory::narrow(files, &changed);
142 assert_eq!(narrowed.rust, vec![PathBuf::from("/root/b.rs")]);
143 assert!(narrowed.markdown.is_empty());
144 }
145
146 #[test]
147 fn walks_tempdir_and_buckets_by_extension() {
148 let tmp = tempfile::tempdir().unwrap();
149 let root = tmp.path();
150 fs::write(root.join("a.rs"), "fn main() {}").unwrap();
151 fs::write(root.join("README.md"), "# hi").unwrap();
152 fs::write(root.join("ci.yml"), "").unwrap();
153 fs::write(root.join("Makefile"), "").unwrap();
154 fs::write(root.join("justfile"), "").unwrap();
155 fs::write(root.join("ignore.txt"), "").unwrap();
156
157 let found = FsWalker::walk(root).unwrap();
158 assert_eq!(found.rust.len(), 1);
159 assert_eq!(found.markdown.len(), 1);
160 assert_eq!(found.yaml.len(), 1);
161 assert_eq!(found.makefiles.len(), 2);
162 }
163}