Skip to main content

spec_drift/sources/
mod.rs

1use 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    /// Walk `root`, respect `.gitignore`, and bucket files by extension.
19    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            // Makefile / justfile have no extension; match by filename.
35            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
57/// `GitHistory` narrows file discovery to only files that have changed relative
58/// to a git ref (typically `HEAD`). Used by `--diff HEAD` so spec-drift can run
59/// as a fast pre-commit check instead of scanning the whole tree.
60///
61/// Shelling out to `git` keeps us free of a linked libgit dependency. When git
62/// is unavailable or the repo has no history, every method returns `None` and
63/// the caller falls back to a full-tree walk.
64pub struct GitHistory;
65
66impl GitHistory {
67    /// Return the set of files changed between `reference` and the working
68    /// tree, relative to `root`. `None` means "can't answer — fall back to a
69    /// full walk" (git missing, not a repo, unknown ref, etc.).
70    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    /// Filter every bucket in `files` to only paths present in `changed`.
90    /// Invariant: buckets stay sorted.
91    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}