use crate::fs::{FileSystem, default_fs};
use crate::model::{AnalysisResult, IssueKind, Module};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snapshot {
pub version: u32,
pub created_at: String,
pub project_name: String,
pub modules: Vec<ModuleSnapshot>,
pub issues: Vec<IssueSnapshot>,
pub dependencies: HashMap<String, Vec<String>>,
pub metrics: SnapshotMetrics,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModuleSnapshot {
pub path: String,
pub name: String,
pub lines: usize,
pub imports: Vec<String>,
pub exports: Vec<String>,
pub content_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueSnapshot {
pub kind: String,
pub severity: String,
pub message: String,
pub locations: Vec<String>,
pub issue_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SnapshotMetrics {
pub total_modules: usize,
pub total_lines: usize,
pub total_dependencies: usize,
pub cycle_count: usize,
pub avg_coupling: f64,
pub max_coupling: usize,
pub issue_counts: HashMap<String, usize>,
}
impl Snapshot {
pub fn from_analysis(result: &AnalysisResult, project_root: &Path) -> Self {
let created_at = chrono_lite_now();
let modules: Vec<ModuleSnapshot> = result
.modules
.iter()
.map(|m| {
let relative_path = m
.path
.strip_prefix(project_root)
.unwrap_or(&m.path)
.display()
.to_string();
let content_hash = compute_file_hash(&m.path);
ModuleSnapshot {
path: relative_path,
name: m.name.clone(),
lines: m.lines,
imports: m.imports.clone(),
exports: m.exports.clone(),
content_hash,
}
})
.collect();
let issues: Vec<IssueSnapshot> = result
.issues
.iter()
.map(|i| {
let locations: Vec<String> = i
.locations
.iter()
.map(|l| {
l.path
.strip_prefix(project_root)
.unwrap_or(&l.path)
.display()
.to_string()
})
.collect();
let issue_id = compute_issue_id(&i.kind, &locations);
let kind_str = format!("{:?}", i.kind);
IssueSnapshot {
kind: kind_str,
severity: i.severity.to_string(),
message: i.message.clone(),
locations,
issue_id,
}
})
.collect();
let mut dependencies: HashMap<String, Vec<String>> = HashMap::new();
for module in &result.modules {
let from_path = module
.path
.strip_prefix(project_root)
.unwrap_or(&module.path)
.display()
.to_string();
let deps: Vec<String> = module
.imports
.iter()
.filter_map(|imp| resolve_to_module(imp, &result.modules, project_root))
.collect();
dependencies.insert(from_path, deps);
}
let metrics = compute_metrics(&modules, &issues, &dependencies);
Self {
version: 1,
created_at,
project_name: result.project_name.clone(),
modules,
issues,
dependencies,
metrics,
}
}
}
pub fn save_snapshot(snapshot: &Snapshot, path: &Path) -> std::io::Result<()> {
save_snapshot_with_fs(snapshot, path, default_fs())
}
pub fn save_snapshot_with_fs(
snapshot: &Snapshot,
path: &Path,
fs: &dyn FileSystem,
) -> std::io::Result<()> {
let json = serde_json::to_string_pretty(snapshot)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
fs.write(path, &json)
}
pub fn load_snapshot(path: &Path) -> Result<Snapshot, Box<dyn std::error::Error>> {
load_snapshot_with_fs(path, default_fs())
}
pub fn load_snapshot_with_fs(
path: &Path,
fs: &dyn FileSystem,
) -> Result<Snapshot, Box<dyn std::error::Error>> {
let content = fs.read_to_string(path)?;
let snapshot: Snapshot = serde_json::from_str(&content)?;
Ok(snapshot)
}
fn compute_file_hash(path: &PathBuf) -> String {
compute_file_hash_with_fs(path, default_fs())
}
fn compute_file_hash_with_fs(path: &PathBuf, fs: &dyn FileSystem) -> String {
use std::collections::hash_map::DefaultHasher;
match fs.read_to_string(path) {
Ok(content) => {
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
Err(_) => String::new(),
}
}
fn compute_issue_id(kind: &IssueKind, locations: &[String]) -> String {
use std::collections::hash_map::DefaultHasher;
let mut hasher = DefaultHasher::new();
format!("{:?}", kind).hash(&mut hasher);
for loc in locations {
loc.hash(&mut hasher);
}
format!("{:x}", hasher.finish())
}
fn resolve_to_module(import: &str, modules: &[Module], project_root: &Path) -> Option<String> {
let segments: Vec<&str> = import.split("::").collect();
if segments.is_empty() {
return None;
}
let search_name = if segments[0] == "crate" && segments.len() > 1 {
segments[1]
} else if segments[0] == "super" || segments[0] == "self" {
return None;
} else {
segments[0]
};
modules.iter().find(|m| m.name == search_name).map(|m| {
m.path
.strip_prefix(project_root)
.unwrap_or(&m.path)
.display()
.to_string()
})
}
fn compute_metrics(
modules: &[ModuleSnapshot],
issues: &[IssueSnapshot],
dependencies: &HashMap<String, Vec<String>>,
) -> SnapshotMetrics {
let total_modules = modules.len();
let total_lines: usize = modules.iter().map(|m| m.lines).sum();
let total_dependencies: usize = dependencies.values().map(|v| v.len()).sum();
let cycle_count = issues
.iter()
.filter(|i| i.kind.contains("CircularDependency"))
.count();
let mut fan_ins: HashMap<&str, usize> = HashMap::new();
for targets in dependencies.values() {
for target in targets {
*fan_ins.entry(target.as_str()).or_insert(0) += 1;
}
}
let max_coupling = fan_ins.values().copied().max().unwrap_or(0);
let avg_coupling = if !fan_ins.is_empty() {
fan_ins.values().sum::<usize>() as f64 / fan_ins.len() as f64
} else {
0.0
};
let mut issue_counts: HashMap<String, usize> = HashMap::new();
for issue in issues {
let base_kind = issue.kind.split('(').next().unwrap_or(&issue.kind);
*issue_counts.entry(base_kind.to_string()).or_insert(0) += 1;
}
SnapshotMetrics {
total_modules,
total_lines,
total_dependencies,
cycle_count,
avg_coupling,
max_coupling,
issue_counts,
}
}
fn chrono_lite_now() -> String {
use std::time::SystemTime;
let duration = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
format!("{}", duration.as_secs())
}