use crate::core::{is_code_file, TESTLIKE_RE};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
use std::process::Command;
const SENTINEL: &str = "__C__";
const MAX_MSG_CHARS: usize = 40_000;
const MAX_MSGS_PER_FILE: usize = 25;
const BULK_COMMIT_FILE_LIMIT: usize = 20;
const MIN_COCHANGE_COUNT: i64 = 3;
const MAX_COCHANGE_PARTNERS: usize = 10;
const MAX_BRIDGE_CANDIDATES: usize = 50;
const MAX_AUTHORS_PER_FILE: usize = 5;
pub type OrderedCounter = IndexMap<String, i64>;
fn counter_incr(c: &mut OrderedCounter, k: &str, by: i64) {
*c.entry(k.to_string()).or_insert(0) += by;
}
fn most_common(c: &OrderedCounter) -> Vec<(String, i64)> {
let mut v: Vec<(String, i64)> = c.iter().map(|(k, n)| (k.clone(), *n)).collect();
v.sort_by(|a, b| b.1.cmp(&a.1));
v
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FileMeta {
pub n_commits: i64,
pub last_ts: i64,
pub authors: IndexMap<String, i64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HistoryData {
pub msgs: IndexMap<String, String>,
pub cochange: IndexMap<String, IndexMap<String, i64>>,
pub meta: IndexMap<String, FileMeta>,
}
fn looks_like_path(ln: &str) -> bool {
let s = ln.trim();
if s.is_empty() || s.contains(' ') || s.contains('\t') {
return false;
}
s.contains('/') || (s.contains('.') && !s.starts_with('.'))
}
fn split_blocks<'a>(lines: &[&'a str]) -> Vec<Vec<&'a str>> {
let mut blocks = Vec::new();
let mut cur: Vec<&str> = Vec::new();
for &ln in lines {
if ln.trim().is_empty() {
if !cur.is_empty() {
blocks.push(std::mem::take(&mut cur));
}
} else {
cur.push(ln);
}
}
if !cur.is_empty() {
blocks.push(cur);
}
blocks
}
fn parse_commit(subject: &str, rest: &[&str]) -> (String, Vec<String>) {
let blocks = split_blocks(rest);
if blocks.is_empty() {
return (subject.to_string(), Vec::new());
}
let last = blocks.last().unwrap();
let (files, body_blocks): (Vec<String>, &[Vec<&str>]) = if last.iter().all(|ln| looks_like_path(ln)) {
(
last.iter().map(|ln| ln.trim().to_string()).collect(),
&blocks[..blocks.len() - 1],
)
} else {
(Vec::new(), &blocks[..])
};
let body = body_blocks
.iter()
.map(|b| b.join("\n"))
.collect::<Vec<_>>()
.join("\n\n");
let message = if body.is_empty() {
subject.to_string()
} else {
format!("{subject}\n{body}")
};
(message, files)
}
fn bridge_cochange(cochange_counts: &IndexMap<String, OrderedCounter>) -> IndexMap<String, OrderedCounter> {
let mut bridges: IndexMap<String, OrderedCounter> = IndexMap::new();
for (t, partners) in cochange_counts {
if !TESTLIKE_RE.is_match(t) {
continue;
}
let mut qualifying: Vec<(String, i64)> = partners
.iter()
.filter(|(f, c)| **c >= MIN_COCHANGE_COUNT && !TESTLIKE_RE.is_match(f))
.map(|(f, c)| (f.clone(), *c))
.collect();
qualifying.sort_by(|a, b| b.1.cmp(&a.1));
qualifying.truncate(MAX_BRIDGE_CANDIDATES);
for i in 0..qualifying.len() {
for j in (i + 1)..qualifying.len() {
let (a, ca) = &qualifying[i];
let (b, cb) = &qualifying[j];
let bridge = ca.min(cb) / 2;
if bridge < 2 {
continue;
}
let cur_ab = bridges.entry(a.clone()).or_default().get(b).copied().unwrap_or(0);
if bridge > cur_ab {
bridges.entry(a.clone()).or_default().insert(b.clone(), bridge);
}
let cur_ba = bridges.entry(b.clone()).or_default().get(a).copied().unwrap_or(0);
if bridge > cur_ba {
bridges.entry(b.clone()).or_default().insert(a.clone(), bridge);
}
}
}
}
bridges
}
pub fn mine_history(
repo_path: &Path,
max_commits: usize,
current_files: Option<&HashSet<String>>,
) -> HistoryData {
if !repo_path.exists() {
return HistoryData::default();
}
let pretty = format!("--pretty=format:{SENTINEL}%at%x00%an%x00%s%n%b");
let output = Command::new("git")
.args([
"log",
"--no-merges",
"-n",
&max_commits.to_string(),
&pretty,
"--name-only",
])
.current_dir(repo_path)
.output();
let output = match output {
Ok(o) if o.status.success() => o,
_ => return HistoryData::default(),
};
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
if stdout.is_empty() {
return HistoryData::default();
}
let lines: Vec<&str> = crate::pyutil::py_splitlines(&stdout);
let headers: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, ln)| ln.starts_with(SENTINEL))
.map(|(i, _)| i)
.collect();
let mut msgs: IndexMap<String, Vec<String>> = IndexMap::new();
let mut cochange_counts: IndexMap<String, OrderedCounter> = IndexMap::new();
let mut n_commits: OrderedCounter = IndexMap::new();
let mut last_ts: IndexMap<String, i64> = IndexMap::new();
let mut authors: IndexMap<String, OrderedCounter> = IndexMap::new();
for (idx, &start) in headers.iter().enumerate() {
let end = headers.get(idx + 1).copied().unwrap_or(lines.len());
let header = &lines[start][SENTINEL.len()..];
let mut parts = header.splitn(3, '\u{0}');
let ts_str = parts.next().unwrap_or("");
let author = parts.next().unwrap_or("");
let subject = parts.next().unwrap_or("");
let ts: i64 = ts_str.parse().unwrap_or(0);
let (_msg, mut files) = parse_commit(subject, &lines[start + 1..end]);
if let Some(fs) = current_files {
files.retain(|f| fs.contains(f));
}
let n_files_total = files.len(); let code_files: Vec<String> = files.into_iter().filter(|f| is_code_file(f)).collect();
if code_files.is_empty() {
continue;
}
for f in &code_files {
counter_incr(&mut n_commits, f, 1);
last_ts.entry(f.clone()).or_insert(ts);
counter_incr(authors.entry(f.clone()).or_default(), author, 1);
let list = msgs.entry(f.clone()).or_default();
if list.len() < MAX_MSGS_PER_FILE {
list.push(_msg.clone());
}
}
if n_files_total <= BULK_COMMIT_FILE_LIMIT {
let mut uniq: Vec<&String> = code_files.iter().collect::<HashSet<_>>().into_iter().collect();
uniq.sort();
if uniq.len() >= 2 {
for i in 0..uniq.len() {
for j in (i + 1)..uniq.len() {
let a = uniq[i].clone();
let b = uniq[j].clone();
counter_incr(cochange_counts.entry(a.clone()).or_default(), &b, 1);
counter_incr(cochange_counts.entry(b).or_default(), &a, 1);
}
}
}
}
}
let mut out_msgs: IndexMap<String, String> = IndexMap::new();
for (f, parts) in &msgs {
let text = parts.join("\n");
let truncated: String = text.chars().take(MAX_MSG_CHARS).collect();
out_msgs.insert(f.clone(), truncated);
}
let mut out_cochange: IndexMap<String, IndexMap<String, i64>> = IndexMap::new();
for (f, counter) in &cochange_counts {
let top: Vec<(String, i64)> = most_common(counter)
.into_iter()
.filter(|(_, n)| *n >= MIN_COCHANGE_COUNT)
.take(MAX_COCHANGE_PARTNERS)
.collect();
if !top.is_empty() {
let m: IndexMap<String, i64> = top.into_iter().collect();
out_cochange.insert(f.clone(), m);
}
}
let bridges = bridge_cochange(&cochange_counts);
for (f, bcounter) in &bridges {
let mut top_bridge_v = most_common(bcounter);
top_bridge_v.truncate(MAX_COCHANGE_PARTNERS);
if top_bridge_v.is_empty() {
continue;
}
let top_bridge: IndexMap<String, i64> = top_bridge_v.into_iter().collect();
let mut merged: IndexMap<String, i64> = out_cochange.get(f).cloned().unwrap_or_default();
for (o, c) in &top_bridge {
let cur = merged.get(o).copied().unwrap_or(0);
merged.insert(o.clone(), cur.max(*c));
}
let mut merged_v: Vec<(String, i64)> = merged.into_iter().collect();
merged_v.sort_by(|a, b| b.1.cmp(&a.1));
merged_v.truncate(MAX_COCHANGE_PARTNERS);
out_cochange.insert(f.clone(), merged_v.into_iter().collect());
}
let mut out_meta: IndexMap<String, FileMeta> = IndexMap::new();
for (f, &n) in &n_commits {
let mut auth_v = most_common(authors.get(f).unwrap());
auth_v.truncate(MAX_AUTHORS_PER_FILE);
out_meta.insert(
f.clone(),
FileMeta {
n_commits: n,
last_ts: *last_ts.get(f).unwrap_or(&0),
authors: auth_v.into_iter().collect(),
},
);
}
HistoryData {
msgs: out_msgs,
cochange: out_cochange,
meta: out_meta,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn looks_like_path_cases() {
assert!(looks_like_path("a/b.py"));
assert!(looks_like_path("foo.py"));
assert!(!looks_like_path(".hidden"));
assert!(!looks_like_path("has space"));
assert!(!looks_like_path(""));
assert!(!looks_like_path("plainword"));
}
#[test]
fn parse_commit_splits_file_list() {
let rest = vec!["body line 1", "", "a/b.py", "c/d.py"];
let (msg, files) = parse_commit("subject", &rest);
assert_eq!(msg, "subject\nbody line 1");
assert_eq!(files, vec!["a/b.py", "c/d.py"]);
}
#[test]
fn parse_commit_no_file_list_block() {
let rest = vec!["not a path list here"];
let (msg, files) = parse_commit("subject", &rest);
assert_eq!(msg, "subject\nnot a path list here");
assert!(files.is_empty());
}
}