1use open_kioku_errors::{OkError, Result};
2use std::collections::HashMap;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7#[derive(Debug, Clone, PartialEq)]
8pub struct CochangeRecord {
9 pub path: PathBuf,
10 pub cochanged_path: PathBuf,
11 pub commit_count: usize,
12 pub recency_weight: f32,
13 pub test_corun: bool,
14 pub commits: Vec<String>,
15}
16
17pub fn discover_root(start: impl AsRef<Path>) -> Result<PathBuf> {
18 let mut current = start.as_ref().canonicalize()?;
19 loop {
20 if current.join(".git").exists() || current.join("ok.toml").exists() {
21 return Ok(current);
22 }
23 if !current.pop() {
24 return Ok(start.as_ref().canonicalize()?);
25 }
26 }
27}
28
29pub fn branch(root: impl AsRef<Path>) -> Option<String> {
30 let head = fs::read_to_string(root.as_ref().join(".git/HEAD")).ok()?;
31 if let Some(value) = head.strip_prefix("ref: refs/heads/") {
32 return Some(value.trim().to_string());
33 }
34 None
35}
36
37pub fn commit(root: impl AsRef<Path>) -> Option<String> {
38 let head = fs::read_to_string(root.as_ref().join(".git/HEAD")).ok()?;
39 if !head.starts_with("ref: ") {
40 return Some(head.trim().to_string());
41 }
42 let reference = head.trim().strip_prefix("ref: ")?;
43 fs::read_to_string(root.as_ref().join(".git").join(reference))
44 .ok()
45 .map(|value| value.trim().to_string())
46}
47
48pub fn require_repo(root: impl AsRef<Path>) -> Result<PathBuf> {
49 let root = discover_root(root)?;
50 if !root.exists() {
51 return Err(OkError::Repository(format!(
52 "repository root does not exist: {}",
53 root.display()
54 )));
55 }
56 Ok(root)
57}
58
59pub fn cochange_records(
60 root: impl AsRef<Path>,
61 max_commits: usize,
62 max_files_per_commit: usize,
63) -> Result<Vec<CochangeRecord>> {
64 let root = root.as_ref();
65 if !root.join(".git").exists() || max_commits == 0 || max_files_per_commit < 2 {
66 return Ok(Vec::new());
67 }
68 let output = Command::new("git")
69 .arg("-C")
70 .arg(root)
71 .arg("log")
72 .arg(format!("--max-count={max_commits}"))
73 .arg("--name-only")
74 .arg("--pretty=format:commit:%H")
75 .output()
76 .map_err(|err| OkError::Repository(format!("git history scan failed: {err}")))?;
77 if !output.status.success() {
78 return Ok(Vec::new());
79 }
80 let stdout = String::from_utf8_lossy(&output.stdout);
81 let mut commits = Vec::new();
82 let mut current_sha: Option<String> = None;
83 let mut current_files = Vec::new();
84 for line in stdout.lines() {
85 if let Some(sha) = line.strip_prefix("commit:") {
86 push_commit(&mut commits, current_sha.take(), &mut current_files);
87 current_sha = Some(sha.trim().to_string());
88 } else {
89 let path = line.trim();
90 if is_history_path(path) {
91 current_files.push(PathBuf::from(path));
92 }
93 }
94 }
95 push_commit(&mut commits, current_sha, &mut current_files);
96
97 let mut pairs: HashMap<(PathBuf, PathBuf), CochangeRecord> = HashMap::new();
98 for (idx, (sha, mut files)) in commits.into_iter().enumerate() {
99 files.sort();
100 files.dedup();
101 if files.len() < 2 || files.len() > max_files_per_commit {
102 continue;
103 }
104 let recency_weight = 1.0 / (1.0 + idx as f32 / 25.0);
105 for left in &files {
106 for right in &files {
107 if left == right {
108 continue;
109 }
110 let key = (left.clone(), right.clone());
111 let entry = pairs.entry(key).or_insert_with(|| CochangeRecord {
112 path: left.clone(),
113 cochanged_path: right.clone(),
114 commit_count: 0,
115 recency_weight: 0.0,
116 test_corun: is_test_path(right),
117 commits: Vec::new(),
118 });
119 entry.commit_count += 1;
120 entry.recency_weight += recency_weight;
121 entry.test_corun |= is_test_path(right);
122 if entry.commits.len() < 5 {
123 entry.commits.push(sha.clone());
124 }
125 }
126 }
127 }
128 let mut records = pairs.into_values().collect::<Vec<_>>();
129 records.sort_by(|a, b| {
130 b.recency_weight
131 .partial_cmp(&a.recency_weight)
132 .unwrap_or(std::cmp::Ordering::Equal)
133 .then_with(|| b.commit_count.cmp(&a.commit_count))
134 .then_with(|| a.path.cmp(&b.path))
135 .then_with(|| a.cochanged_path.cmp(&b.cochanged_path))
136 });
137 Ok(records)
138}
139
140fn push_commit(
141 commits: &mut Vec<(String, Vec<PathBuf>)>,
142 sha: Option<String>,
143 files: &mut Vec<PathBuf>,
144) {
145 if let Some(sha) = sha {
146 commits.push((sha, std::mem::take(files)));
147 }
148}
149
150fn is_history_path(path: &str) -> bool {
151 !path.is_empty()
152 && !path.ends_with('/')
153 && !path.starts_with(".git/")
154 && !path.starts_with(".ok/")
155 && !path.contains("=>")
156}
157
158fn is_test_path(path: &Path) -> bool {
159 let value = path.to_string_lossy().to_ascii_lowercase();
160 value.contains("/test/")
161 || value.contains("/tests/")
162 || value.ends_with("_test.rs")
163 || value.ends_with("_test.go")
164 || value.ends_with(".test.ts")
165 || value.ends_with(".spec.ts")
166 || value.ends_with("test.java")
167 || value.ends_with("tests.java")
168}
169
170#[cfg(test)]
171mod tests {
172 use super::cochange_records;
173 use std::process::Command;
174
175 #[test]
176 fn cochange_records_apply_recency_and_test_corun() {
177 let dir = tempfile::tempdir().unwrap();
178 run(dir.path(), &["init"]);
179 run(dir.path(), &["config", "user.email", "test@example.com"]);
180 run(dir.path(), &["config", "user.name", "Test User"]);
181
182 write(dir.path(), "src/old.rs", "fn old() {}\n");
183 write(
184 dir.path(),
185 "tests/old_test.rs",
186 "#[test] fn old_test() {}\n",
187 );
188 run(dir.path(), &["add", "."]);
189 run(dir.path(), &["commit", "-m", "old pair"]);
190
191 write(dir.path(), "src/new.rs", "fn new() {}\n");
192 write(
193 dir.path(),
194 "tests/new_test.rs",
195 "#[test] fn new_test() {}\n",
196 );
197 run(dir.path(), &["add", "."]);
198 run(dir.path(), &["commit", "-m", "new pair"]);
199
200 let records = cochange_records(dir.path(), 20, 10).unwrap();
201 let new_pair = records
202 .iter()
203 .find(|record| {
204 record.path == std::path::Path::new("src/new.rs")
205 && record.cochanged_path == std::path::Path::new("tests/new_test.rs")
206 })
207 .unwrap();
208 let old_pair = records
209 .iter()
210 .find(|record| {
211 record.path == std::path::Path::new("src/old.rs")
212 && record.cochanged_path == std::path::Path::new("tests/old_test.rs")
213 })
214 .unwrap();
215
216 assert!(new_pair.test_corun);
217 assert!(new_pair.recency_weight > old_pair.recency_weight);
218 assert_eq!(new_pair.commit_count, 1);
219 }
220
221 fn write(root: &std::path::Path, path: &str, content: &str) {
222 let path = root.join(path);
223 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
224 std::fs::write(path, content).unwrap();
225 }
226
227 fn run(root: &std::path::Path, args: &[&str]) {
228 let status = Command::new("git")
229 .arg("-C")
230 .arg(root)
231 .args(args)
232 .status()
233 .unwrap();
234 assert!(status.success(), "git {args:?} failed");
235 }
236}