use super::serialize::{IssueSnapshot, ModuleSnapshot, Snapshot};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone)]
pub struct SnapshotDiff {
pub baseline_created_at: String,
pub current_created_at: String,
pub added_modules: Vec<String>,
pub removed_modules: Vec<String>,
pub modified_modules: Vec<ModuleChange>,
pub added_dependencies: Vec<(String, String)>,
pub removed_dependencies: Vec<(String, String)>,
pub new_issues: Vec<IssueSnapshot>,
pub resolved_issues: Vec<IssueSnapshot>,
pub metric_changes: MetricChanges,
}
#[derive(Debug, Clone)]
pub struct ModuleChange {
pub path: String,
pub old_lines: usize,
pub new_lines: usize,
pub imports_added: Vec<String>,
pub imports_removed: Vec<String>,
pub exports_added: Vec<String>,
pub exports_removed: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct MetricChanges {
pub module_count_delta: i64,
pub line_count_delta: i64,
pub dependency_count_delta: i64,
pub cycle_count_delta: i64,
pub coupling_delta: f64,
pub new_issue_count: usize,
pub resolved_issue_count: usize,
}
pub fn compute_diff(baseline: &Snapshot, current: &Snapshot) -> SnapshotDiff {
let baseline_paths: HashSet<&str> = baseline.modules.iter().map(|m| m.path.as_str()).collect();
let current_paths: HashSet<&str> = current.modules.iter().map(|m| m.path.as_str()).collect();
let added_modules: Vec<String> = current_paths
.difference(&baseline_paths)
.map(|s| s.to_string())
.collect();
let removed_modules: Vec<String> = baseline_paths
.difference(¤t_paths)
.map(|s| s.to_string())
.collect();
let baseline_map: HashMap<&str, &ModuleSnapshot> = baseline
.modules
.iter()
.map(|m| (m.path.as_str(), m))
.collect();
let current_map: HashMap<&str, &ModuleSnapshot> = current
.modules
.iter()
.map(|m| (m.path.as_str(), m))
.collect();
let modified_modules: Vec<ModuleChange> = baseline_paths
.intersection(¤t_paths)
.filter_map(|path| {
let base = baseline_map.get(path)?;
let curr = current_map.get(path)?;
if base.content_hash != curr.content_hash {
let base_imports: HashSet<&String> = base.imports.iter().collect();
let curr_imports: HashSet<&String> = curr.imports.iter().collect();
let base_exports: HashSet<&String> = base.exports.iter().collect();
let curr_exports: HashSet<&String> = curr.exports.iter().collect();
Some(ModuleChange {
path: path.to_string(),
old_lines: base.lines,
new_lines: curr.lines,
imports_added: curr_imports
.difference(&base_imports)
.map(|s| (*s).clone())
.collect(),
imports_removed: base_imports
.difference(&curr_imports)
.map(|s| (*s).clone())
.collect(),
exports_added: curr_exports
.difference(&base_exports)
.map(|s| (*s).clone())
.collect(),
exports_removed: base_exports
.difference(&curr_exports)
.map(|s| (*s).clone())
.collect(),
})
} else {
None
}
})
.collect();
let baseline_deps: HashSet<(String, String)> = flatten_dependencies(&baseline.dependencies);
let current_deps: HashSet<(String, String)> = flatten_dependencies(¤t.dependencies);
let added_dependencies: Vec<(String, String)> =
current_deps.difference(&baseline_deps).cloned().collect();
let removed_dependencies: Vec<(String, String)> =
baseline_deps.difference(¤t_deps).cloned().collect();
let baseline_issue_ids: HashSet<&str> = baseline
.issues
.iter()
.map(|i| i.issue_id.as_str())
.collect();
let current_issue_ids: HashSet<&str> =
current.issues.iter().map(|i| i.issue_id.as_str()).collect();
let new_issues: Vec<IssueSnapshot> = current
.issues
.iter()
.filter(|i| !baseline_issue_ids.contains(i.issue_id.as_str()))
.cloned()
.collect();
let resolved_issues: Vec<IssueSnapshot> = baseline
.issues
.iter()
.filter(|i| !current_issue_ids.contains(i.issue_id.as_str()))
.cloned()
.collect();
let metric_changes = MetricChanges {
module_count_delta: current.metrics.total_modules as i64
- baseline.metrics.total_modules as i64,
line_count_delta: current.metrics.total_lines as i64 - baseline.metrics.total_lines as i64,
dependency_count_delta: current.metrics.total_dependencies as i64
- baseline.metrics.total_dependencies as i64,
cycle_count_delta: current.metrics.cycle_count as i64 - baseline.metrics.cycle_count as i64,
coupling_delta: current.metrics.avg_coupling - baseline.metrics.avg_coupling,
new_issue_count: new_issues.len(),
resolved_issue_count: resolved_issues.len(),
};
SnapshotDiff {
baseline_created_at: baseline.created_at.clone(),
current_created_at: current.created_at.clone(),
added_modules,
removed_modules,
modified_modules,
added_dependencies,
removed_dependencies,
new_issues,
resolved_issues,
metric_changes,
}
}
fn flatten_dependencies(deps: &HashMap<String, Vec<String>>) -> HashSet<(String, String)> {
deps.iter()
.flat_map(|(from, tos)| tos.iter().map(move |to| (from.clone(), to.clone())))
.collect()
}
pub fn format_diff_markdown(diff: &SnapshotDiff) -> String {
let mut output = String::new();
output.push_str("# Architectural Diff\n\n");
output.push_str(&format!(
"**Baseline**: {} | **Current**: {}\n\n",
diff.baseline_created_at, diff.current_created_at
));
output.push_str("## Summary\n\n");
let metrics = &diff.metric_changes;
output.push_str(&format!(
"- **Modules**: {} ({})\n",
format_delta(metrics.module_count_delta),
format!(
"+{} added, -{} removed, {} modified",
diff.added_modules.len(),
diff.removed_modules.len(),
diff.modified_modules.len()
)
));
output.push_str(&format!(
"- **Lines**: {}\n",
format_delta(metrics.line_count_delta)
));
output.push_str(&format!(
"- **Dependencies**: {} (+{} / -{})\n",
format_delta(metrics.dependency_count_delta),
diff.added_dependencies.len(),
diff.removed_dependencies.len()
));
output.push_str(&format!(
"- **Cycles**: {}\n",
format_delta(metrics.cycle_count_delta)
));
output.push_str(&format!(
"- **Avg Coupling**: {:+.2}\n\n",
metrics.coupling_delta
));
if !diff.new_issues.is_empty() {
output.push_str(&format!("## New Issues ({})\n\n", diff.new_issues.len()));
for issue in &diff.new_issues {
output.push_str(&format!(
"- **{}** [{}]: {}\n",
issue.kind, issue.severity, issue.message
));
for loc in &issue.locations {
output.push_str(&format!(" - `{}`\n", loc));
}
}
output.push('\n');
}
if !diff.resolved_issues.is_empty() {
output.push_str(&format!(
"## Resolved Issues ({})\n\n",
diff.resolved_issues.len()
));
for issue in &diff.resolved_issues {
output.push_str(&format!("- ~~**{}**: {}~~\n", issue.kind, issue.message));
}
output.push('\n');
}
if !diff.added_modules.is_empty() {
output.push_str(&format!(
"## Added Modules ({})\n\n",
diff.added_modules.len()
));
for module in &diff.added_modules {
output.push_str(&format!("- `{}`\n", module));
}
output.push('\n');
}
if !diff.removed_modules.is_empty() {
output.push_str(&format!(
"## Removed Modules ({})\n\n",
diff.removed_modules.len()
));
for module in &diff.removed_modules {
output.push_str(&format!("- `{}`\n", module));
}
output.push('\n');
}
if !diff.modified_modules.is_empty() {
output.push_str(&format!(
"## Modified Modules ({})\n\n",
diff.modified_modules.len()
));
for module in &diff.modified_modules {
let line_delta = module.new_lines as i64 - module.old_lines as i64;
output.push_str(&format!(
"### `{}` ({} lines)\n",
module.path,
format_delta(line_delta)
));
if !module.imports_added.is_empty() {
output.push_str(&format!(
"- Imports added: {}\n",
module.imports_added.join(", ")
));
}
if !module.imports_removed.is_empty() {
output.push_str(&format!(
"- Imports removed: {}\n",
module.imports_removed.join(", ")
));
}
if !module.exports_added.is_empty() {
output.push_str(&format!(
"- Exports added: {}\n",
module.exports_added.join(", ")
));
}
if !module.exports_removed.is_empty() {
output.push_str(&format!(
"- Exports removed: {}\n",
module.exports_removed.join(", ")
));
}
output.push('\n');
}
}
output
}
pub fn format_diff_json(diff: &SnapshotDiff) -> String {
use serde_json::json;
let output = json!({
"baseline_created_at": diff.baseline_created_at,
"current_created_at": diff.current_created_at,
"summary": {
"module_count_delta": diff.metric_changes.module_count_delta,
"line_count_delta": diff.metric_changes.line_count_delta,
"dependency_count_delta": diff.metric_changes.dependency_count_delta,
"cycle_count_delta": diff.metric_changes.cycle_count_delta,
"coupling_delta": diff.metric_changes.coupling_delta,
"new_issue_count": diff.metric_changes.new_issue_count,
"resolved_issue_count": diff.metric_changes.resolved_issue_count
},
"added_modules": diff.added_modules,
"removed_modules": diff.removed_modules,
"modified_modules": diff.modified_modules.iter().map(|m| {
json!({
"path": m.path,
"old_lines": m.old_lines,
"new_lines": m.new_lines,
"imports_added": m.imports_added,
"imports_removed": m.imports_removed,
"exports_added": m.exports_added,
"exports_removed": m.exports_removed
})
}).collect::<Vec<_>>(),
"added_dependencies": diff.added_dependencies.iter().map(|(from, to)| {
json!({"from": from, "to": to})
}).collect::<Vec<_>>(),
"removed_dependencies": diff.removed_dependencies.iter().map(|(from, to)| {
json!({"from": from, "to": to})
}).collect::<Vec<_>>(),
"new_issues": diff.new_issues.iter().map(|i| {
json!({
"kind": i.kind,
"severity": i.severity,
"message": i.message,
"locations": i.locations
})
}).collect::<Vec<_>>(),
"resolved_issues": diff.resolved_issues.iter().map(|i| {
json!({
"kind": i.kind,
"severity": i.severity,
"message": i.message,
"locations": i.locations
})
}).collect::<Vec<_>>()
});
serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
}
fn format_delta(delta: i64) -> String {
if delta > 0 {
format!("+{}", delta)
} else if delta < 0 {
format!("{}", delta)
} else {
"0".to_string()
}
}