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!(file_name, "Makefile" | "makefile" | "GNUmakefile" | "justfile") {
36 out.makefiles.push(path);
37 continue;
38 }
39
40 match path.extension().and_then(|e| e.to_str()) {
41 Some("rs") => out.rust.push(path),
42 Some("md") | Some("markdown") => out.markdown.push(path),
43 Some("yaml") | Some("yml") => out.yaml.push(path),
44 Some("mk") => out.makefiles.push(path),
45 _ => {}
46 }
47 }
48
49 out.rust.sort();
50 out.markdown.sort();
51 out.yaml.sort();
52 out.makefiles.sort();
53 Ok(out)
54 }
55}
56
57pub struct GitHistory;
65
66impl GitHistory {
67 pub fn changed_files(root: &Path, reference: &str) -> Option<HashSet<PathBuf>> {
71 let out = Command::new("git")
72 .current_dir(root)
73 .args(["diff", "--name-only", reference])
74 .output()
75 .ok()?;
76 if !out.status.success() {
77 return None;
78 }
79 let text = String::from_utf8(out.stdout).ok()?;
80 let set: HashSet<PathBuf> = text
81 .lines()
82 .map(|l| l.trim())
83 .filter(|l| !l.is_empty())
84 .map(|l| root.join(l))
85 .collect();
86 Some(set)
87 }
88
89 pub fn narrow(files: DiscoveredFiles, changed: &HashSet<PathBuf>) -> DiscoveredFiles {
92 fn keep(v: Vec<PathBuf>, changed: &HashSet<PathBuf>) -> Vec<PathBuf> {
93 let mut v: Vec<PathBuf> = v.into_iter().filter(|p| changed.contains(p)).collect();
94 v.sort();
95 v
96 }
97 DiscoveredFiles {
98 rust: keep(files.rust, changed),
99 markdown: keep(files.markdown, changed),
100 yaml: keep(files.yaml, changed),
101 makefiles: keep(files.makefiles, changed),
102 }
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use std::fs;
110
111 #[test]
112 fn git_history_narrow_keeps_only_changed_paths() {
113 let mut files = DiscoveredFiles::default();
114 files.rust.push(PathBuf::from("/root/a.rs"));
115 files.rust.push(PathBuf::from("/root/b.rs"));
116 files.markdown.push(PathBuf::from("/root/README.md"));
117
118 let mut changed = HashSet::new();
119 changed.insert(PathBuf::from("/root/b.rs"));
120
121 let narrowed = GitHistory::narrow(files, &changed);
122 assert_eq!(narrowed.rust, vec![PathBuf::from("/root/b.rs")]);
123 assert!(narrowed.markdown.is_empty());
124 }
125
126 #[test]
127 fn walks_tempdir_and_buckets_by_extension() {
128 let tmp = tempfile::tempdir().unwrap();
129 let root = tmp.path();
130 fs::write(root.join("a.rs"), "fn main() {}").unwrap();
131 fs::write(root.join("README.md"), "# hi").unwrap();
132 fs::write(root.join("ci.yml"), "").unwrap();
133 fs::write(root.join("Makefile"), "").unwrap();
134 fs::write(root.join("justfile"), "").unwrap();
135 fs::write(root.join("ignore.txt"), "").unwrap();
136
137 let found = FsWalker::walk(root).unwrap();
138 assert_eq!(found.rust.len(), 1);
139 assert_eq!(found.markdown.len(), 1);
140 assert_eq!(found.yaml.len(), 1);
141 assert_eq!(found.makefiles.len(), 2);
142 }
143}