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!(
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
60/// `GitHistory` discovers files that have changed relative to a git ref
61/// (typically `HEAD`). Used by `--diff HEAD` so spec-drift can focus reports on
62/// changed files and drift plausibly induced by changed implementation.
63///
64/// Shelling out to `git` keeps us free of a linked libgit dependency. When git
65/// is unavailable or the repo has no history, every method returns `None` and
66/// the caller falls back to a full-tree walk.
67pub struct GitHistory;
68
69impl GitHistory {
70    /// Return the set of tracked files changed between `reference` and the
71    /// working tree, plus untracked files that are not ignored. `None` means
72    /// "can't answer — fall back to a full walk" (git missing, not a repo,
73    /// unknown ref, etc.).
74    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    /// Filter every bucket in `files` to only paths present in `changed`.
110    /// Invariant: buckets stay sorted.
111    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}