use crate::db::GraphDb;
use crate::repograph::facts::{
label_of, list_prop, neighbors, neighbors_both, owner_name, rank, score_of, symbol_file,
};
use crate::repograph::render::sanitize;
use crate::Direction;
use core_storage::fs::Fs;
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet};
const MAX_SYMBOLS: usize = 6;
pub const DEFAULT_EXCLUDES: [&str; 6] = [
"target/",
"node_modules/",
"dist/",
".git/",
"*.lock",
"*.min.js",
];
#[must_use]
pub fn path_excluded(path: &str, patterns: &[String]) -> bool {
patterns.iter().any(|p| {
if let Some(prefix) = p.strip_suffix('/') {
path.starts_with(&format!("{prefix}/"))
} else if let Some(suffix) = p.strip_prefix('*').filter(|s| s.starts_with('.')) {
path.len() > suffix.len() && path.ends_with(suffix)
} else {
path.contains(p.as_str())
}
})
}
#[derive(Debug, Clone, PartialEq)]
pub struct ImpactOptions {
pub min_score: f64,
pub min_shared_commits: usize,
pub max_partners: usize,
pub max_importers: usize,
}
impl Default for ImpactOptions {
fn default() -> Self {
Self {
min_score: 0.3,
min_shared_commits: MIN_SHARED_COMMITS,
max_partners: 6,
max_importers: 6,
}
}
}
pub const MIN_SHARED_COMMITS: usize = 3;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Partner {
pub path: String,
pub score: f64,
pub shared_commits: Option<usize>,
pub modified: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct FileImpact {
pub path: String,
pub owner: Option<String>,
pub partners: Vec<Partner>,
pub importers: Vec<Partner>,
pub symbols_used_elsewhere: Vec<(String, usize)>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ImpactReport {
pub files: Vec<FileImpact>,
pub unknown: Vec<String>,
}
#[must_use]
pub fn impact<F: Fs>(
db: &GraphDb<F>,
files: &[String],
modified: &BTreeSet<String>,
opts: &ImpactOptions,
) -> ImpactReport {
let mut wanted: Vec<&String> = files.iter().collect();
wanted.sort();
wanted.dedup();
let mut report = ImpactReport {
files: Vec::new(),
unknown: Vec::new(),
};
for path in wanted {
if label_of(db, path).as_deref() != Some("File") {
report.unknown.push(sanitize(path));
continue;
}
report.files.push(FileImpact {
path: sanitize(path),
owner: owner_name(db, path).map(|n| sanitize(&n)),
partners: partners(db, path, modified, opts),
importers: importers(db, path, modified, opts),
symbols_used_elsewhere: used_elsewhere(db, path),
});
}
report
}
fn partners<F: Fs>(
db: &GraphDb<F>,
path: &str,
modified: &BTreeSet<String>,
opts: &ImpactOptions,
) -> Vec<Partner> {
let mut scored: Vec<(String, f64)> = neighbors_both(db, path, "CO_CHANGED")
.into_iter()
.map(|other| {
let score = score_of(db, "CO_CHANGED", path, &other).unwrap_or(0.0);
(other, score)
})
.filter(|(_, score)| *score >= opts.min_score)
.collect();
rank(&mut scored);
scored.truncate(opts.max_partners);
let named: BTreeSet<String> = scored.iter().map(|(other, _)| other.clone()).collect();
let mut out: Vec<Partner> = scored
.into_iter()
.map(|(other, score)| Partner {
modified: modified.contains(&other),
path: sanitize(&other),
score,
shared_commits: None,
})
.collect();
for (other, shared) in frequent_partners(db, path, &named, opts.min_shared_commits) {
if out.len() >= opts.max_partners {
break;
}
out.push(Partner {
modified: modified.contains(&other),
path: sanitize(&other),
score: 0.0,
shared_commits: Some(shared),
});
}
out
}
fn frequent_partners<F: Fs>(
db: &GraphDb<F>,
path: &str,
skip: &BTreeSet<String>,
min: usize,
) -> Vec<(String, usize)> {
if min == 0 {
return Vec::new();
}
let mine: BTreeSet<String> = list_prop(db, path, "commits").into_iter().collect();
if mine.is_empty() {
return Vec::new();
}
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for sha in &mine {
for other in neighbors(db, sha, "TOUCHED", Direction::Out) {
if other != path && !skip.contains(&other) {
*counts.entry(other).or_default() += 1;
}
}
}
let mut out: Vec<(String, usize)> = counts.into_iter().filter(|(_, n)| *n >= min).collect();
out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
out
}
fn importers<F: Fs>(
db: &GraphDb<F>,
path: &str,
modified: &BTreeSet<String>,
opts: &ImpactOptions,
) -> Vec<Partner> {
neighbors(db, path, "IMPORTS", Direction::In)
.into_iter()
.take(opts.max_importers)
.map(|other| Partner {
modified: modified.contains(&other),
path: sanitize(&other),
score: 1.0,
shared_commits: None,
})
.collect()
}
fn used_elsewhere<F: Fs>(db: &GraphDb<F>, path: &str) -> Vec<(String, usize)> {
let mut out: Vec<(String, usize)> = Vec::new();
for symbol in neighbors(db, path, "DEFINES", Direction::In) {
let callers = neighbors(db, &symbol, "CALLS", Direction::In)
.into_iter()
.filter(|caller| symbol_file(db, caller).as_deref() != Some(path))
.count();
if callers > 0 {
out.push((sanitize(&symbol), callers));
}
}
rank(&mut out);
out.truncate(MAX_SYMBOLS);
out
}