Skip to main content

cargo_coupling/
volatility.rs

1//! Git history analysis for volatility measurement
2//!
3//! Analyzes git log to determine how frequently files change.
4//! Optimized for large repositories using streaming and git path filtering.
5
6use std::collections::HashMap;
7use std::io::{BufRead, BufReader};
8use std::path::Path;
9use std::process::{Command, Stdio};
10
11use thiserror::Error;
12
13/// Volatility levels (how often a component changes)
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
15pub enum Volatility {
16    /// Rarely changes (0-2 times)
17    Low,
18    /// Sometimes changes (3-10 times)
19    Medium,
20    /// Frequently changes (11+ times)
21    High,
22}
23
24impl Volatility {
25    /// Returns the numeric value (0.0 - 1.0, higher = more volatile)
26    pub fn value(&self) -> f64 {
27        match self {
28            Volatility::Low => 0.0,
29            Volatility::Medium => 0.5,
30            Volatility::High => 1.0,
31        }
32    }
33
34    /// Classify from change count
35    pub fn from_count(count: usize) -> Self {
36        match count {
37            0..=2 => Volatility::Low,
38            3..=10 => Volatility::Medium,
39            _ => Volatility::High,
40        }
41    }
42}
43
44/// Errors that can occur during volatility analysis
45#[derive(Error, Debug)]
46pub enum VolatilityError {
47    #[error("Failed to execute git command: {0}")]
48    GitCommand(#[from] std::io::Error),
49
50    #[error("Invalid UTF-8 in git output: {0}")]
51    InvalidUtf8(#[from] std::string::FromUtf8Error),
52
53    #[error("Not a git repository")]
54    NotGitRepo,
55}
56
57/// Volatility analyzer using git history
58#[derive(Debug, Default)]
59pub struct VolatilityAnalyzer {
60    /// File path -> change count
61    pub file_changes: HashMap<String, usize>,
62    /// Analysis period in months
63    pub period_months: usize,
64}
65
66impl VolatilityAnalyzer {
67    /// Create a new volatility analyzer
68    pub fn new(period_months: usize) -> Self {
69        Self {
70            file_changes: HashMap::new(),
71            period_months,
72        }
73    }
74
75    /// Analyze git history for a repository (optimized version)
76    ///
77    /// Optimizations applied:
78    /// 1. Use `-- "*.rs"` to filter .rs files at git level
79    /// 2. Use streaming with BufReader instead of loading all into memory
80    /// 3. Use `--diff-filter=AMRC` to skip deleted files
81    pub fn analyze(&mut self, repo_path: &Path) -> Result<(), VolatilityError> {
82        // Check if it's a git repo
83        let git_check = Command::new("git")
84            .args(["rev-parse", "--git-dir"])
85            .current_dir(repo_path)
86            .stderr(Stdio::null())
87            .output()?;
88
89        if !git_check.status.success() {
90            return Err(VolatilityError::NotGitRepo);
91        }
92
93        // Optimized: use --diff-filter and path spec to reduce output
94        // --diff-filter=AMRC: Added, Modified, Renamed, Copied (skip Deleted)
95        let mut child = Command::new("git")
96            .args([
97                "log",
98                "--pretty=format:",
99                "--name-only",
100                "--diff-filter=AMRC",
101                &format!("--since={} months ago", self.period_months),
102                "--",
103                "*.rs",
104            ])
105            .current_dir(repo_path)
106            .stdout(Stdio::piped())
107            .stderr(Stdio::null())
108            .spawn()?;
109
110        // Stream processing with BufReader
111        if let Some(stdout) = child.stdout.take() {
112            let reader = BufReader::with_capacity(64 * 1024, stdout); // 64KB buffer
113
114            for line in reader.lines() {
115                let line = match line {
116                    Ok(l) => l,
117                    Err(_) => continue,
118                };
119
120                let line = line.trim();
121                if !line.is_empty() && line.ends_with(".rs") {
122                    *self.file_changes.entry(line.to_string()).or_insert(0) += 1;
123                }
124            }
125        }
126
127        // Wait for git to finish
128        let _ = child.wait();
129
130        Ok(())
131    }
132
133    /// Get volatility level for a file
134    pub fn get_volatility(&self, file_path: &str) -> Volatility {
135        let count = self.file_changes.get(file_path).copied().unwrap_or(0);
136        Volatility::from_count(count)
137    }
138
139    /// Get change count for a file
140    pub fn get_change_count(&self, file_path: &str) -> usize {
141        self.file_changes.get(file_path).copied().unwrap_or(0)
142    }
143
144    /// Get all high volatility files
145    pub fn high_volatility_files(&self) -> Vec<(&String, usize)> {
146        self.file_changes
147            .iter()
148            .filter(|&(_, count)| *count > 10)
149            .map(|(path, count)| (path, *count))
150            .collect()
151    }
152
153    /// Analyze temporal coupling (co-change patterns) from git history
154    ///
155    /// Detects files that frequently change together in the same commit,
156    /// indicating implicit coupling that AST analysis cannot detect.
157    /// Based on Khononov's modularity model: co-changing files suggest
158    /// shared knowledge even without explicit code dependencies.
159    pub fn analyze_temporal_coupling(
160        &self,
161        repo_path: &Path,
162    ) -> Result<Vec<TemporalCoupling>, VolatilityError> {
163        // Get commit-grouped file changes
164        let mut child = Command::new("git")
165            .args([
166                "log",
167                "--pretty=format:__COMMIT__",
168                "--name-only",
169                "--diff-filter=AMRC",
170                &format!("--since={} months ago", self.period_months),
171                "--",
172                "*.rs",
173            ])
174            .current_dir(repo_path)
175            .stdout(Stdio::piped())
176            .stderr(Stdio::null())
177            .spawn()?;
178
179        let mut commits: Vec<Vec<String>> = Vec::new();
180        let mut current_files: Vec<String> = Vec::new();
181
182        if let Some(stdout) = child.stdout.take() {
183            let reader = BufReader::with_capacity(64 * 1024, stdout);
184            for line in reader.lines() {
185                let line = match line {
186                    Ok(l) => l,
187                    Err(_) => continue,
188                };
189                let trimmed = line.trim();
190                if trimmed == "__COMMIT__" {
191                    if current_files.len() >= 2 {
192                        commits.push(std::mem::take(&mut current_files));
193                    } else {
194                        current_files.clear();
195                    }
196                } else if !trimmed.is_empty() && trimmed.ends_with(".rs") {
197                    current_files.push(trimmed.to_string());
198                }
199            }
200            // Don't forget the last commit
201            if current_files.len() >= 2 {
202                commits.push(current_files);
203            }
204        }
205
206        let _ = child.wait();
207
208        // Count co-change frequency for each file pair
209        // Skip commits with too many files (e.g., formatter runs, merge commits)
210        // as they produce O(n²) noise rather than meaningful coupling signal
211        const MAX_FILES_PER_COMMIT: usize = 50;
212        let mut pair_counts: HashMap<(String, String), usize> = HashMap::new();
213        for changed_files in &commits {
214            if changed_files.len() > MAX_FILES_PER_COMMIT {
215                continue;
216            }
217            for left_index in 0..changed_files.len() {
218                for right_index in (left_index + 1)..changed_files.len() {
219                    let (first_file, second_file) =
220                        if changed_files[left_index] < changed_files[right_index] {
221                            (
222                                changed_files[left_index].clone(),
223                                changed_files[right_index].clone(),
224                            )
225                        } else {
226                            (
227                                changed_files[right_index].clone(),
228                                changed_files[left_index].clone(),
229                            )
230                        };
231                    *pair_counts.entry((first_file, second_file)).or_default() += 1;
232                }
233            }
234        }
235
236        // Filter to significant co-changes (3+ times together)
237        let mut result: Vec<TemporalCoupling> = pair_counts
238            .into_iter()
239            .filter(|(_, count)| *count >= 3)
240            .map(|((file_a, file_b), count)| {
241                let total_a = self.file_changes.get(&file_a).copied().unwrap_or(1);
242                let total_b = self.file_changes.get(&file_b).copied().unwrap_or(1);
243                let coupling_ratio = count as f64 / total_a.min(total_b).max(1) as f64;
244                TemporalCoupling {
245                    file_a,
246                    file_b,
247                    co_change_count: count,
248                    coupling_ratio: coupling_ratio.min(1.0),
249                }
250            })
251            .collect();
252
253        result.sort_by(|a, b| {
254            b.co_change_count
255                .cmp(&a.co_change_count)
256                .then_with(|| {
257                    b.coupling_ratio
258                        .partial_cmp(&a.coupling_ratio)
259                        .unwrap_or(std::cmp::Ordering::Equal)
260                })
261                .then_with(|| a.file_a.cmp(&b.file_a))
262                .then_with(|| a.file_b.cmp(&b.file_b))
263        });
264        Ok(result)
265    }
266
267    /// Get volatility statistics
268    pub fn statistics(&self) -> VolatilityStats {
269        if self.file_changes.is_empty() {
270            return VolatilityStats::default();
271        }
272
273        let counts: Vec<usize> = self.file_changes.values().copied().collect();
274        let total: usize = counts.iter().sum();
275        let max = counts.iter().max().copied().unwrap_or(0);
276        let min = counts.iter().min().copied().unwrap_or(0);
277        let avg = total as f64 / counts.len() as f64;
278
279        let low_count = counts.iter().filter(|&&c| c <= 2).count();
280        let medium_count = counts.iter().filter(|&&c| c > 2 && c <= 10).count();
281        let high_count = counts.iter().filter(|&&c| c > 10).count();
282
283        VolatilityStats {
284            total_files: counts.len(),
285            total_changes: total,
286            max_changes: max,
287            min_changes: min,
288            avg_changes: avg,
289            low_volatility_count: low_count,
290            medium_volatility_count: medium_count,
291            high_volatility_count: high_count,
292        }
293    }
294}
295
296/// Temporal coupling between two files (co-change pattern)
297///
298/// Represents files that frequently change together in git commits,
299/// indicating implicit coupling beyond what code structure reveals.
300#[derive(Debug, Clone)]
301pub struct TemporalCoupling {
302    /// First file in the pair
303    pub file_a: String,
304    /// Second file in the pair
305    pub file_b: String,
306    /// Number of commits where both files changed together
307    pub co_change_count: usize,
308    /// Ratio of co-changes to total changes of the less-changed file (0.0-1.0)
309    pub coupling_ratio: f64,
310}
311
312impl TemporalCoupling {
313    /// Whether this represents strong temporal coupling (>50% co-change ratio)
314    pub fn is_strong(&self) -> bool {
315        self.coupling_ratio >= 0.5
316    }
317}
318
319/// Statistics about volatility across the project
320#[derive(Debug, Default)]
321pub struct VolatilityStats {
322    /// Number of Rust files with git change data.
323    pub total_files: usize,
324    /// Total observed file changes across the analysis window.
325    pub total_changes: usize,
326    /// Highest change count for one file.
327    pub max_changes: usize,
328    /// Lowest change count for one file.
329    pub min_changes: usize,
330    /// Average change count per file.
331    pub avg_changes: f64,
332    /// Number of files classified as low volatility.
333    pub low_volatility_count: usize,
334    /// Number of files classified as medium volatility.
335    pub medium_volatility_count: usize,
336    /// Number of files classified as high volatility.
337    pub high_volatility_count: usize,
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn test_volatility_classification() {
346        let mut analyzer = VolatilityAnalyzer::new(6);
347        analyzer.file_changes.insert("stable.rs".to_string(), 1);
348        analyzer.file_changes.insert("moderate.rs".to_string(), 5);
349        analyzer.file_changes.insert("volatile.rs".to_string(), 15);
350
351        assert_eq!(analyzer.get_volatility("stable.rs"), Volatility::Low);
352        assert_eq!(analyzer.get_volatility("moderate.rs"), Volatility::Medium);
353        assert_eq!(analyzer.get_volatility("volatile.rs"), Volatility::High);
354        assert_eq!(analyzer.get_volatility("unknown.rs"), Volatility::Low);
355    }
356
357    #[test]
358    fn test_high_volatility_files() {
359        let mut analyzer = VolatilityAnalyzer::new(6);
360        analyzer.file_changes.insert("stable.rs".to_string(), 2);
361        analyzer.file_changes.insert("volatile.rs".to_string(), 15);
362        analyzer
363            .file_changes
364            .insert("very_volatile.rs".to_string(), 25);
365
366        let high_vol = analyzer.high_volatility_files();
367        assert_eq!(high_vol.len(), 2);
368    }
369
370    #[test]
371    fn test_statistics() {
372        let mut analyzer = VolatilityAnalyzer::new(6);
373        analyzer.file_changes.insert("a.rs".to_string(), 1);
374        analyzer.file_changes.insert("b.rs".to_string(), 5);
375        analyzer.file_changes.insert("c.rs".to_string(), 15);
376
377        let stats = analyzer.statistics();
378        assert_eq!(stats.total_files, 3);
379        assert_eq!(stats.total_changes, 21);
380        assert_eq!(stats.max_changes, 15);
381        assert_eq!(stats.min_changes, 1);
382        assert_eq!(stats.low_volatility_count, 1);
383        assert_eq!(stats.medium_volatility_count, 1);
384        assert_eq!(stats.high_volatility_count, 1);
385    }
386
387    #[test]
388    fn test_temporal_coupling_is_strong() {
389        let strong = TemporalCoupling {
390            file_a: "a.rs".to_string(),
391            file_b: "b.rs".to_string(),
392            co_change_count: 10,
393            coupling_ratio: 0.8,
394        };
395        assert!(strong.is_strong());
396
397        let exactly_threshold = TemporalCoupling {
398            file_a: "a.rs".to_string(),
399            file_b: "b.rs".to_string(),
400            co_change_count: 5,
401            coupling_ratio: 0.5,
402        };
403        assert!(exactly_threshold.is_strong());
404
405        let weak = TemporalCoupling {
406            file_a: "a.rs".to_string(),
407            file_b: "b.rs".to_string(),
408            co_change_count: 3,
409            coupling_ratio: 0.3,
410        };
411        assert!(!weak.is_strong());
412    }
413}