Skip to main content

fallow_engine/
churn.rs

1//! Git churn helpers and types exposed through the engine boundary.
2
3use std::path::{Path, PathBuf};
4use std::process::{Command, Output};
5
6pub use fallow_types::churn::ChurnTrend;
7use rustc_hash::FxHashMap;
8
9/// Function pointer signature used to intercept git churn subprocesses.
10pub type ChurnSpawnHook = fn(&mut Command) -> std::io::Result<Output>;
11
12/// Parsed duration for the `--since` flag.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct SinceDuration {
15    /// Value to pass to `git log --after`.
16    pub git_after: String,
17    /// Human-readable display string.
18    pub display: String,
19}
20
21impl From<fallow_core::churn::SinceDuration> for SinceDuration {
22    fn from(duration: fallow_core::churn::SinceDuration) -> Self {
23        Self {
24            git_after: duration.git_after,
25            display: duration.display,
26        }
27    }
28}
29
30impl From<&SinceDuration> for fallow_core::churn::SinceDuration {
31    fn from(duration: &SinceDuration) -> Self {
32        Self {
33            git_after: duration.git_after.clone(),
34            display: duration.display.clone(),
35        }
36    }
37}
38
39/// Per-author commit aggregation for a single file.
40#[derive(Debug, Clone, Copy, PartialEq)]
41pub struct AuthorContribution {
42    /// Total commits by this author touching this file in the analysis window.
43    pub commits: u32,
44    /// Recency-weighted commit sum.
45    pub weighted_commits: f64,
46    /// Earliest commit timestamp by this author.
47    pub first_commit_ts: u64,
48    /// Latest commit timestamp by this author.
49    pub last_commit_ts: u64,
50}
51
52impl From<fallow_core::churn::AuthorContribution> for AuthorContribution {
53    fn from(author: fallow_core::churn::AuthorContribution) -> Self {
54        Self {
55            commits: author.commits,
56            weighted_commits: author.weighted_commits,
57            first_commit_ts: author.first_commit_ts,
58            last_commit_ts: author.last_commit_ts,
59        }
60    }
61}
62
63/// Per-file churn data collected from git history.
64#[derive(Debug, Clone)]
65pub struct FileChurn {
66    /// Absolute file path.
67    pub path: PathBuf,
68    /// Total number of commits touching this file in the analysis window.
69    pub commits: u32,
70    /// Recency-weighted commit count.
71    pub weighted_commits: f64,
72    /// Total lines added across all commits.
73    pub lines_added: u32,
74    /// Total lines deleted across all commits.
75    pub lines_deleted: u32,
76    /// Churn trend: accelerating, stable, or cooling.
77    pub trend: ChurnTrend,
78    /// Per-author contributions keyed by interned author index.
79    pub authors: FxHashMap<u32, AuthorContribution>,
80}
81
82impl From<fallow_core::churn::FileChurn> for FileChurn {
83    fn from(file: fallow_core::churn::FileChurn) -> Self {
84        Self {
85            path: file.path,
86            commits: file.commits,
87            weighted_commits: file.weighted_commits,
88            lines_added: file.lines_added,
89            lines_deleted: file.lines_deleted,
90            trend: file.trend,
91            authors: file
92                .authors
93                .into_iter()
94                .map(|(index, author)| (index, AuthorContribution::from(author)))
95                .collect(),
96        }
97    }
98}
99
100/// Result of churn analysis.
101#[derive(Debug, Clone)]
102pub struct ChurnResult {
103    /// Per-file churn data, keyed by absolute path.
104    pub files: FxHashMap<PathBuf, FileChurn>,
105    /// Whether the repository is a shallow clone.
106    pub shallow_clone: bool,
107    /// Author email pool.
108    pub author_pool: Vec<String>,
109}
110
111impl From<fallow_core::churn::ChurnResult> for ChurnResult {
112    fn from(result: fallow_core::churn::ChurnResult) -> Self {
113        Self {
114            files: result
115                .files
116                .into_iter()
117                .map(|(path, file)| (path, FileChurn::from(file)))
118                .collect(),
119            shallow_clone: result.shallow_clone,
120            author_pool: result.author_pool,
121        }
122    }
123}
124
125/// Install a spawn hook for git churn analysis.
126pub fn set_spawn_hook(hook: ChurnSpawnHook) {
127    fallow_core::churn::set_spawn_hook(hook);
128}
129
130/// Parse a `--since` value into a git-compatible duration.
131///
132/// # Errors
133///
134/// Returns an error if the input is not a supported duration or ISO date.
135pub fn parse_since(input: &str) -> Result<SinceDuration, String> {
136    fallow_core::churn::parse_since(input).map(SinceDuration::from)
137}
138
139/// Analyze git churn for files under `root`.
140#[must_use]
141pub fn analyze_churn(root: &Path, since: &SinceDuration) -> Option<ChurnResult> {
142    let since = fallow_core::churn::SinceDuration::from(since);
143    fallow_core::churn::analyze_churn(root, &since).map(ChurnResult::from)
144}
145
146/// Analyze churn from a normalized `fallow-churn/v1` file.
147///
148/// # Errors
149///
150/// Returns an error when the import file cannot be read, parsed, or validated.
151pub fn analyze_churn_from_file(path: &Path, root: &Path) -> Result<ChurnResult, String> {
152    fallow_core::churn::analyze_churn_from_file(path, root).map(ChurnResult::from)
153}
154
155/// Check whether `root` is inside a git repository.
156#[must_use]
157pub fn is_git_repo(root: &Path) -> bool {
158    fallow_core::churn::is_git_repo(root)
159}
160
161/// Analyze churn with disk caching.
162#[must_use]
163pub fn analyze_churn_cached(
164    root: &Path,
165    since: &SinceDuration,
166    cache_dir: &Path,
167    no_cache: bool,
168) -> Option<(ChurnResult, bool)> {
169    let since = fallow_core::churn::SinceDuration::from(since);
170    fallow_core::churn::analyze_churn_cached(root, &since, cache_dir, no_cache)
171        .map(|(result, cache_hit)| (ChurnResult::from(result), cache_hit))
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn parse_since_returns_engine_owned_duration() {
180        let duration = parse_since("6m").expect("duration should parse");
181        assert_eq!(duration.git_after, "6 months ago");
182        assert_eq!(duration.display, "6 months");
183    }
184
185    #[test]
186    fn churn_result_converts_from_core_without_leaking_type() {
187        let mut authors = FxHashMap::default();
188        authors.insert(
189            0,
190            fallow_core::churn::AuthorContribution {
191                commits: 2,
192                weighted_commits: 1.5,
193                first_commit_ts: 10,
194                last_commit_ts: 20,
195            },
196        );
197        let mut files = FxHashMap::default();
198        files.insert(
199            PathBuf::from("/repo/src/a.ts"),
200            fallow_core::churn::FileChurn {
201                path: PathBuf::from("/repo/src/a.ts"),
202                commits: 2,
203                weighted_commits: 1.5,
204                lines_added: 10,
205                lines_deleted: 4,
206                trend: ChurnTrend::Stable,
207                authors,
208            },
209        );
210        let result = ChurnResult::from(fallow_core::churn::ChurnResult {
211            files,
212            shallow_clone: true,
213            author_pool: vec!["dev@example.com".to_string()],
214        });
215
216        let file = result
217            .files
218            .get(&PathBuf::from("/repo/src/a.ts"))
219            .expect("converted file churn");
220        assert!(result.shallow_clone);
221        assert_eq!(result.author_pool, ["dev@example.com"]);
222        assert_eq!(file.commits, 2);
223        assert_eq!(file.authors[&0].last_commit_ts, 20);
224    }
225}