1use std::collections::HashMap;
7use std::io::{BufRead, BufReader};
8use std::path::Path;
9use std::process::{Command, Stdio};
10
11use thiserror::Error;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
15pub enum Volatility {
16 Low,
18 Medium,
20 High,
22}
23
24impl Volatility {
25 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 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#[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#[derive(Debug, Default)]
59pub struct VolatilityAnalyzer {
60 pub file_changes: HashMap<String, usize>,
62 pub period_months: usize,
64}
65
66impl VolatilityAnalyzer {
67 pub fn new(period_months: usize) -> Self {
69 Self {
70 file_changes: HashMap::new(),
71 period_months,
72 }
73 }
74
75 pub fn analyze(&mut self, repo_path: &Path) -> Result<(), VolatilityError> {
82 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 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 if let Some(stdout) = child.stdout.take() {
112 let reader = BufReader::with_capacity(64 * 1024, stdout); 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 let _ = child.wait();
129
130 Ok(())
131 }
132
133 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 pub fn get_change_count(&self, file_path: &str) -> usize {
141 self.file_changes.get(file_path).copied().unwrap_or(0)
142 }
143
144 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 pub fn analyze_temporal_coupling(
160 &self,
161 repo_path: &Path,
162 ) -> Result<Vec<TemporalCoupling>, VolatilityError> {
163 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 if current_files.len() >= 2 {
202 commits.push(current_files);
203 }
204 }
205
206 let _ = child.wait();
207
208 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 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 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#[derive(Debug, Clone)]
301pub struct TemporalCoupling {
302 pub file_a: String,
304 pub file_b: String,
306 pub co_change_count: usize,
308 pub coupling_ratio: f64,
310}
311
312impl TemporalCoupling {
313 pub fn is_strong(&self) -> bool {
315 self.coupling_ratio >= 0.5
316 }
317}
318
319#[derive(Debug, Default)]
321pub struct VolatilityStats {
322 pub total_files: usize,
324 pub total_changes: usize,
326 pub max_changes: usize,
328 pub min_changes: usize,
330 pub avg_changes: f64,
332 pub low_volatility_count: usize,
334 pub medium_volatility_count: usize,
336 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}