use std::collections::{BTreeSet, HashMap};
use std::path::Path;
use anyhow::Result;
use crate::access::RepoFilter;
use crate::config::Config;
use crate::index::git::{calculate_coupling_score, GitAnalyzer};
use crate::storage::MetadataStore;
use crate::types::CrossRepoCoupling;
pub type BeadFileMap = HashMap<String, (BTreeSet<String>, i64)>;
type PairKey = ((String, String), (String, String));
const MAX_PAIRS_PER_BEAD_REPO_PAIR: usize = 400;
#[derive(Debug, Clone, PartialEq)]
pub struct CrossRepoRelated {
pub repo: String,
pub path: String,
pub score: f32,
pub co_changes: u32,
}
fn canonical<'a>(
a: (&'a str, &'a str),
b: (&'a str, &'a str),
) -> ((&'a str, &'a str), (&'a str, &'a str)) {
if a <= b {
(a, b)
} else {
(b, a)
}
}
pub fn pair_cross_repo(
repos: &[(String, BeadFileMap)],
freq_weight: f32,
recency_days: f32,
now: i64,
) -> Vec<CrossRepoCoupling> {
let mut acc: HashMap<PairKey, (u32, i64)> = HashMap::new();
let mut all_beads: BTreeSet<&str> = BTreeSet::new();
for (_, map) in repos {
for bead in map.keys() {
all_beads.insert(bead.as_str());
}
}
for bead in all_beads {
let present: Vec<usize> = repos
.iter()
.enumerate()
.filter(|(_, (_, map))| map.contains_key(bead))
.map(|(i, _)| i)
.collect();
if present.len() < 2 {
continue; }
for ii in 0..present.len() {
for jj in (ii + 1)..present.len() {
let (repo_i, map_i) = &repos[present[ii]];
let (repo_j, map_j) = &repos[present[jj]];
if repo_i == repo_j {
continue;
}
let (files_i, ts_i) = &map_i[bead];
let (files_j, ts_j) = &map_j[bead];
let last = (*ts_i).max(*ts_j);
let mut emitted = 0usize;
'outer: for fi in files_i {
for fj in files_j {
let (lo, hi) = canonical((repo_i, fi), (repo_j, fj));
let key = (
(lo.0.to_string(), lo.1.to_string()),
(hi.0.to_string(), hi.1.to_string()),
);
let e = acc.entry(key).or_insert((0, 0));
e.0 += 1;
if last > e.1 {
e.1 = last;
}
emitted += 1;
if emitted >= MAX_PAIRS_PER_BEAD_REPO_PAIR {
break 'outer;
}
}
}
}
}
}
let max_co = acc.values().map(|(c, _)| *c).max().unwrap_or(0);
let mut out: Vec<CrossRepoCoupling> = acc
.into_iter()
.map(|((a, b), (co_changes, last_co_change))| CrossRepoCoupling {
repo_a: a.0,
path_a: a.1,
repo_b: b.0,
path_b: b.1,
score: calculate_coupling_score(
co_changes,
max_co,
last_co_change,
now,
freq_weight,
recency_days,
),
co_changes,
last_co_change,
})
.collect();
out.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
out
}
pub fn compute_and_store_cross_repo(ms: &MetadataStore, config: &Config) -> Result<usize> {
ms.clear_cross_repo_coupling()?;
if config.groups.is_empty() {
return Ok(0);
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let mut total = 0usize;
ms.begin_transaction()?;
let result = (|| -> Result<()> {
for group in &config.groups {
let mut repos: Vec<(String, BeadFileMap)> = Vec::new();
let mut seen: BTreeSet<&str> = BTreeSet::new();
for repo_name in &group.repos {
if !seen.insert(repo_name.as_str()) {
continue; }
let src = match ms.get_meta(&format!("repo_source:{}", repo_name))? {
Some(s) => s,
None => continue,
};
let analyzer = match GitAnalyzer::new(Path::new(&src)) {
Ok(a) => a,
Err(_) => continue,
};
let map = analyzer.bead_file_map(config.git.coupling_depth)?;
if !map.is_empty() {
repos.push((repo_name.clone(), map));
}
}
if repos.len() < 2 {
continue;
}
let pairs = pair_cross_repo(
&repos,
config.git.coupling_freq_weight,
config.git.coupling_recency_days,
now,
);
for p in &pairs {
ms.upsert_cross_repo_coupling(p)?;
total += 1;
}
}
Ok(())
})();
match result {
Ok(()) => {
ms.commit()?;
Ok(total)
}
Err(e) => {
let _ = ms.rollback();
Err(e)
}
}
}
pub fn related_cross_repo(
store: &MetadataStore,
seed_repo: Option<&str>,
seed_path: &str,
limit: usize,
threshold: f32,
filter: &RepoFilter,
) -> Result<Vec<CrossRepoRelated>> {
let edges = store.get_cross_repo_coupling(seed_repo, seed_path, limit)?;
let mut out = Vec::new();
for e in edges {
if e.score < threshold {
continue;
}
let seed_is_a = e.path_a == seed_path && seed_repo.is_none_or(|r| r == e.repo_a);
let (other_repo, other_path) = if seed_is_a {
(e.repo_b, e.path_b)
} else {
(e.repo_a, e.path_a)
};
if other_repo.trim().is_empty() || other_path.trim().is_empty() {
continue;
}
let synthetic = format!("repos/{}/{}", other_repo, other_path);
if !filter.is_path_allowed(&synthetic) {
continue;
}
out.push(CrossRepoRelated {
repo: other_repo,
path: other_path,
score: e.score,
co_changes: e.co_changes,
});
}
Ok(out)
}
#[cfg(test)]
#[path = "cross_repo_tests.rs"]
mod tests;