use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SemanticData {
pub crate_name: String,
pub type_cardinalities: Vec<ResolvedTypeCardinality>,
pub function_cardinalities: Vec<ResolvedFunctionCardinality>,
pub call_edges: Vec<CallEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedTypeCardinality {
pub file: String,
pub module_path: String,
pub name: String,
pub cardinality_log2: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedFunctionCardinality {
pub file: String,
pub module_path: String,
pub name: String,
pub line: usize,
pub internal_state_cardinality_log2: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CallEdge {
pub caller_module: String,
pub caller_file: String,
pub callee_module: String,
pub callee_file: String,
#[serde(default)]
pub caller_function: String,
#[serde(default)]
pub caller_line: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SemanticOverlay {
pub type_cardinalities: BTreeMap<(String, String, String), f64>,
pub function_cardinalities: BTreeMap<(String, String, String, usize), f64>,
pub coupling: CouplingData,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CouplingData {
pub density: f64,
pub module_count: usize,
pub edge_count: usize,
pub module_outgoing_edges: BTreeMap<String, usize>,
#[serde(default)]
pub all_modules: BTreeSet<String>,
#[serde(default)]
pub module_files: BTreeMap<String, String>,
#[serde(default)]
pub function_outgoing_edges: BTreeMap<(String, String, usize), usize>,
#[serde(default)]
pub function_files: BTreeMap<(String, String, usize), String>,
}
impl crate::metrics::SemanticSummary {
pub fn from_overlay(overlay: &SemanticOverlay) -> Self {
crate::metrics::SemanticSummary {
coupling_density: overlay.coupling.density,
coupling_module_count: overlay.coupling.module_count,
coupling_edge_count: overlay.coupling.edge_count,
}
}
}
pub fn paths_suffix_match(a: &str, b: &str) -> bool {
if a == b {
return true;
}
let (longer, shorter) = if a.len() >= b.len() { (a, b) } else { (b, a) };
longer.ends_with(shorter) && longer.as_bytes()[longer.len() - shorter.len() - 1] == b'/'
}
impl SemanticOverlay {
pub fn from_data(data: &SemanticData) -> Self {
let mut type_cardinalities = BTreeMap::new();
for tc in &data.type_cardinalities {
type_cardinalities.insert(
(tc.file.clone(), tc.module_path.clone(), tc.name.clone()),
tc.cardinality_log2,
);
}
let mut function_cardinalities = BTreeMap::new();
for fc in &data.function_cardinalities {
function_cardinalities.insert(
(
fc.file.clone(),
fc.module_path.clone(),
fc.name.clone(),
fc.line,
),
fc.internal_state_cardinality_log2,
);
}
let coupling = Self::compute_coupling(&data.call_edges);
SemanticOverlay {
type_cardinalities,
function_cardinalities,
coupling,
}
}
fn compute_coupling(edges: &[CallEdge]) -> CouplingData {
let mut modules: BTreeSet<String> = BTreeSet::new();
let mut directed_edges: BTreeSet<(String, String)> = BTreeSet::new();
let mut module_outgoing: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut module_files: BTreeMap<String, String> = BTreeMap::new();
for edge in edges {
if edge.caller_module == edge.callee_module {
continue; }
modules.insert(edge.caller_module.clone());
modules.insert(edge.callee_module.clone());
directed_edges.insert((edge.caller_module.clone(), edge.callee_module.clone()));
module_outgoing
.entry(edge.caller_module.clone())
.or_default()
.insert(edge.callee_module.clone());
module_files
.entry(edge.caller_module.clone())
.or_insert_with(|| edge.caller_file.clone());
}
let n = modules.len();
let e = directed_edges.len();
let density = if n <= 1 {
0.0
} else {
e as f64 / (n * (n - 1)) as f64
};
let module_outgoing_edges = module_outgoing
.into_iter()
.map(|(m, targets)| (m, targets.len()))
.collect();
let mut fn_outgoing: BTreeMap<(String, String, usize), BTreeSet<String>> = BTreeMap::new();
let mut function_files: BTreeMap<(String, String, usize), String> = BTreeMap::new();
for edge in edges {
if edge.caller_module == edge.callee_module {
continue;
}
if edge.caller_function.is_empty() {
continue;
}
let key = (
edge.caller_module.clone(),
edge.caller_function.clone(),
edge.caller_line,
);
fn_outgoing
.entry(key.clone())
.or_default()
.insert(edge.callee_module.clone());
function_files
.entry(key)
.or_insert_with(|| edge.caller_file.clone());
}
let function_outgoing_edges = fn_outgoing
.into_iter()
.map(|(k, targets)| (k, targets.len()))
.collect();
CouplingData {
density,
module_count: n,
edge_count: e,
module_outgoing_edges,
all_modules: modules,
module_files,
function_outgoing_edges,
function_files,
}
}
pub fn type_cardinality(&self, file: &str, module_path: &str, name: &str) -> Option<f64> {
let key = (file.to_string(), module_path.to_string(), name.to_string());
if let Some(&v) = self.type_cardinalities.get(&key) {
return Some(v);
}
for ((stored_file, _stored_mod, stored_name), &v) in &self.type_cardinalities {
if stored_name == name && paths_suffix_match(stored_file, file) {
return Some(v);
}
}
None
}
pub fn function_cardinality(
&self,
file: &str,
module_path: &str,
name: &str,
line: usize,
) -> Option<f64> {
let key = (
file.to_string(),
module_path.to_string(),
name.to_string(),
line,
);
if let Some(&v) = self.function_cardinalities.get(&key) {
return Some(v);
}
for ((stored_file, _stored_mod, stored_name, stored_line), &v) in
&self.function_cardinalities
{
if stored_name == name && *stored_line == line && paths_suffix_match(stored_file, file)
{
return Some(v);
}
}
None
}
pub fn load(path: &std::path::Path) -> Result<Self, String> {
let content = std::fs::read_to_string(path).map_err(|e| {
format!(
"Failed to read semantic data from {}: {}",
path.display(),
e
)
})?;
let data: SemanticData = serde_json::from_str(&content).map_err(|e| {
format!(
"Failed to parse semantic data from {}: {}",
path.display(),
e
)
})?;
Ok(Self::from_data(&data))
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_from_data_empty() {
let data = SemanticData::default();
let overlay = SemanticOverlay::from_data(&data);
assert!(overlay.type_cardinalities.is_empty());
assert!(overlay.function_cardinalities.is_empty());
assert!((overlay.coupling.density - 0.0).abs() < f64::EPSILON);
assert_eq!(overlay.coupling.module_count, 0);
assert_eq!(overlay.coupling.edge_count, 0);
}
#[test]
fn test_coupling_density_with_edges() {
let data = SemanticData {
crate_name: "test".into(),
type_cardinalities: Vec::new(),
function_cardinalities: Vec::new(),
call_edges: vec![
CallEdge {
caller_module: "a".into(),
caller_file: "a.rs".into(),
callee_module: "b".into(),
callee_file: "b.rs".into(),
caller_function: "do_stuff".into(),
caller_line: 10,
},
CallEdge {
caller_module: "b".into(),
caller_file: "b.rs".into(),
callee_module: "c".into(),
callee_file: "c.rs".into(),
caller_function: "handle".into(),
caller_line: 20,
},
CallEdge {
caller_module: "a".into(),
caller_file: "a.rs".into(),
callee_module: "a".into(),
callee_file: "a.rs".into(),
caller_function: "internal".into(),
caller_line: 30,
},
],
};
let overlay = SemanticOverlay::from_data(&data);
assert_eq!(overlay.coupling.module_count, 3);
assert_eq!(overlay.coupling.edge_count, 2);
assert!((overlay.coupling.density - 1.0 / 3.0).abs() < 1e-10);
assert_eq!(overlay.coupling.module_outgoing_edges.len(), 2);
assert_eq!(overlay.coupling.module_outgoing_edges["a"], 1);
assert_eq!(overlay.coupling.module_outgoing_edges["b"], 1);
}
#[test]
fn test_full_coupling_density() {
let data = SemanticData {
crate_name: "test".into(),
type_cardinalities: Vec::new(),
function_cardinalities: Vec::new(),
call_edges: vec![
CallEdge {
caller_module: "a".into(),
caller_file: "a.rs".into(),
callee_module: "b".into(),
callee_file: "b.rs".into(),
caller_function: "call_b".into(),
caller_line: 5,
},
CallEdge {
caller_module: "b".into(),
caller_file: "b.rs".into(),
callee_module: "a".into(),
callee_file: "a.rs".into(),
caller_function: "call_a".into(),
caller_line: 15,
},
],
};
let overlay = SemanticOverlay::from_data(&data);
assert!((overlay.coupling.density - 1.0).abs() < 1e-10);
}
#[test]
fn test_type_and_function_cardinality_lookups() {
let data = SemanticData {
crate_name: "test".into(),
type_cardinalities: vec![ResolvedTypeCardinality {
file: "lib.rs".into(),
module_path: "net".into(),
name: "Connection".into(),
cardinality_log2: 4.0,
}],
function_cardinalities: vec![ResolvedFunctionCardinality {
file: "lib.rs".into(),
module_path: "net".into(),
name: "connect".into(),
line: 42,
internal_state_cardinality_log2: 3.0,
}],
call_edges: Vec::new(),
};
let overlay = SemanticOverlay::from_data(&data);
assert_eq!(
overlay.type_cardinality("lib.rs", "net", "Connection"),
Some(4.0)
);
assert_eq!(overlay.type_cardinality("lib.rs", "net", "Missing"), None);
assert_eq!(
overlay.function_cardinality("lib.rs", "net", "connect", 42),
Some(3.0)
);
assert_eq!(
overlay.function_cardinality("lib.rs", "net", "connect", 99),
None
);
}
#[test]
fn test_paths_suffix_match() {
assert!(paths_suffix_match("agents/mod.rs", "agents/mod.rs"));
assert!(paths_suffix_match(
"crates/myapp/src/agents/mod.rs",
"agents/mod.rs"
));
assert!(paths_suffix_match(
"agents/mod.rs",
"crates/myapp/src/agents/mod.rs"
));
assert!(!paths_suffix_match("barfoo/mod.rs", "foo/mod.rs"));
assert!(!paths_suffix_match("other_mod.rs", "mod.rs"));
assert!(paths_suffix_match("mod.rs", "mod.rs"));
assert!(!paths_suffix_match("a.rs", "b.rs"));
}
#[test]
fn test_type_cardinality_suffix_match() {
let data = SemanticData {
crate_name: "test".into(),
type_cardinalities: vec![ResolvedTypeCardinality {
file: "crates/myapp/src/agents/mod.rs".into(),
module_path: "agents".into(),
name: "AgentState".into(),
cardinality_log2: 5.0,
}],
function_cardinalities: Vec::new(),
call_edges: Vec::new(),
};
let overlay = SemanticOverlay::from_data(&data);
assert_eq!(
overlay.type_cardinality("agents/mod.rs", "agents", "AgentState"),
Some(5.0)
);
assert_eq!(
overlay.type_cardinality("crates/myapp/src/agents/mod.rs", "agents", "AgentState"),
Some(5.0)
);
assert_eq!(
overlay.type_cardinality("agents/mod.rs", "agents", "Missing"),
None
);
}
#[test]
fn test_type_cardinality_suffix_match_reverse() {
let data = SemanticData {
crate_name: "test".into(),
type_cardinalities: vec![ResolvedTypeCardinality {
file: "agents/mod.rs".into(),
module_path: "agents".into(),
name: "AgentState".into(),
cardinality_log2: 5.0,
}],
function_cardinalities: Vec::new(),
call_edges: Vec::new(),
};
let overlay = SemanticOverlay::from_data(&data);
assert_eq!(
overlay.type_cardinality("crates/myapp/src/agents/mod.rs", "agents", "AgentState"),
Some(5.0)
);
}
#[test]
fn test_function_cardinality_suffix_match() {
let data = SemanticData {
crate_name: "test".into(),
type_cardinalities: Vec::new(),
function_cardinalities: vec![ResolvedFunctionCardinality {
file: "crates/myapp/src/net/client.rs".into(),
module_path: "net".into(),
name: "connect".into(),
line: 10,
internal_state_cardinality_log2: 3.0,
}],
call_edges: Vec::new(),
};
let overlay = SemanticOverlay::from_data(&data);
assert_eq!(
overlay.function_cardinality("net/client.rs", "net", "connect", 10),
Some(3.0)
);
assert_eq!(
overlay.function_cardinality("crates/myapp/src/net/client.rs", "net", "connect", 10),
Some(3.0)
);
assert_eq!(
overlay.function_cardinality("net/client.rs", "net", "connect", 99),
None
);
}
#[test]
fn test_roundtrip_serialization() {
let data = SemanticData {
crate_name: "test".into(),
type_cardinalities: vec![ResolvedTypeCardinality {
file: "lib.rs".into(),
module_path: "".into(),
name: "Foo".into(),
cardinality_log2: 2.0,
}],
function_cardinalities: Vec::new(),
call_edges: vec![CallEdge {
caller_module: "a".into(),
caller_file: "a.rs".into(),
callee_module: "b".into(),
callee_file: "b.rs".into(),
caller_function: "init".into(),
caller_line: 1,
}],
};
let json = serde_json::to_string(&data).expect("serialize");
let parsed: SemanticData = serde_json::from_str(&json).expect("deserialize");
let overlay = SemanticOverlay::from_data(&parsed);
assert_eq!(overlay.type_cardinality("lib.rs", "", "Foo"), Some(2.0));
assert_eq!(overlay.coupling.edge_count, 1);
}
#[test]
fn test_function_outgoing_edges_populated() {
let data = SemanticData {
crate_name: "test".into(),
type_cardinalities: Vec::new(),
function_cardinalities: Vec::new(),
call_edges: vec![
CallEdge {
caller_module: "a".into(),
caller_file: "a.rs".into(),
callee_module: "b".into(),
callee_file: "b.rs".into(),
caller_function: "fn_one".into(),
caller_line: 10,
},
CallEdge {
caller_module: "a".into(),
caller_file: "a.rs".into(),
callee_module: "c".into(),
callee_file: "c.rs".into(),
caller_function: "fn_one".into(),
caller_line: 10,
},
CallEdge {
caller_module: "a".into(),
caller_file: "a.rs".into(),
callee_module: "b".into(),
callee_file: "b.rs".into(),
caller_function: "fn_two".into(),
caller_line: 30,
},
],
};
let overlay = SemanticOverlay::from_data(&data);
assert_eq!(
overlay.coupling.function_outgoing_edges[&("a".into(), "fn_one".into(), 10)],
2
);
assert_eq!(
overlay.coupling.function_outgoing_edges[&("a".into(), "fn_two".into(), 30)],
1
);
assert_eq!(
overlay.coupling.function_files[&("a".into(), "fn_one".into(), 10)],
"a.rs"
);
}
#[test]
fn test_call_edge_backward_compat_deserialization() {
let json = r#"{
"caller_module": "a",
"caller_file": "a.rs",
"callee_module": "b",
"callee_file": "b.rs"
}"#;
let edge: CallEdge = serde_json::from_str(json).expect("deserialize");
assert_eq!(edge.caller_function, "");
assert_eq!(edge.caller_line, 0);
}
}