use crate::output::unified::{
CouplingClassification, FileDebtItemOutput, UnifiedDebtItemOutput, UnifiedLocation,
};
use crate::priority::UnifiedAnalysis;
use std::collections::{HashMap, HashSet};
use std::io::{self, Write};
#[derive(Debug, Clone)]
pub struct DotConfig {
pub min_score: Option<f64>,
pub max_depth: Option<usize>,
pub include_external: bool,
pub cluster_by_module: bool,
pub rankdir: RankDir,
}
impl Default for DotConfig {
fn default() -> Self {
Self {
min_score: None,
max_depth: None,
include_external: false,
cluster_by_module: true,
rankdir: RankDir::TopBottom,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub enum RankDir {
#[default]
TopBottom,
LeftRight,
}
impl RankDir {
fn as_str(&self) -> &'static str {
match self {
RankDir::TopBottom => "TB",
RankDir::LeftRight => "LR",
}
}
}
pub struct DotWriter {
config: DotConfig,
}
impl DotWriter {
pub fn new() -> Self {
Self {
config: DotConfig::default(),
}
}
pub fn with_config(config: DotConfig) -> Self {
Self { config }
}
pub fn write<W: Write>(&self, analysis: &UnifiedAnalysis, out: &mut W) -> io::Result<()> {
let items = self.collect_file_items(analysis);
let graph = self.build_dependency_graph(&items);
self.write_dot(&items, &graph, out)
}
fn collect_file_items(&self, analysis: &UnifiedAnalysis) -> Vec<FileDebtItemOutput> {
use crate::output::unified::convert_to_unified_format;
let unified = convert_to_unified_format(analysis, false);
unified
.items
.into_iter()
.filter_map(|item| match item {
UnifiedDebtItemOutput::File(file_item) => {
if let Some(min) = self.config.min_score {
if file_item.score < min {
return None;
}
}
Some(*file_item)
}
UnifiedDebtItemOutput::Function(_) => None,
})
.collect()
}
fn build_dependency_graph(&self, items: &[FileDebtItemOutput]) -> HashMap<String, Vec<String>> {
let mut graph: HashMap<String, Vec<String>> = HashMap::new();
let known_files: HashSet<String> = items
.iter()
.map(|item| item.location.file.clone())
.collect();
for item in items {
let file_path = &item.location.file;
if let Some(deps) = &item.dependencies {
let dependencies: Vec<String> = deps
.top_dependencies
.iter()
.filter(|dep| {
self.config.include_external || known_files.contains(*dep)
})
.cloned()
.collect();
graph.insert(file_path.clone(), dependencies);
} else {
graph.insert(file_path.clone(), vec![]);
}
}
graph
}
fn write_dot<W: Write>(
&self,
items: &[FileDebtItemOutput],
graph: &HashMap<String, Vec<String>>,
out: &mut W,
) -> io::Result<()> {
self.write_graph_header(out)?;
self.write_legend(out)?;
writeln!(out)?;
self.write_all_nodes(items, out)?;
writeln!(out)?;
self.write_edges(out, graph)?;
writeln!(out, "}}")?;
Ok(())
}
fn write_graph_header<W: Write>(&self, out: &mut W) -> io::Result<()> {
writeln!(out, "digraph debtmap {{")?;
writeln!(out, " rankdir={};", self.config.rankdir.as_str())?;
writeln!(
out,
" node [shape=box, style=filled, fontname=\"Helvetica\"];"
)?;
writeln!(out, " edge [fontname=\"Helvetica\", fontsize=10];")?;
writeln!(out)?;
Ok(())
}
fn write_all_nodes<W: Write>(
&self,
items: &[FileDebtItemOutput],
out: &mut W,
) -> io::Result<()> {
if self.config.cluster_by_module {
let modules = self.group_by_module(items);
for (module, module_items) in &modules {
self.write_cluster(out, module, module_items)?;
}
} else {
for item in items {
self.write_node(out, item, " ")?;
}
}
Ok(())
}
fn write_legend<W: Write>(&self, out: &mut W) -> io::Result<()> {
writeln!(out, " subgraph cluster_legend {{")?;
writeln!(out, " label=\"Debt Score Legend\";")?;
writeln!(out, " style=rounded;")?;
writeln!(out, " bgcolor=\"#F0F0F0\";")?;
writeln!(out, " fontname=\"Helvetica\";")?;
writeln!(out)?;
writeln!(out, " legend_critical [label=\"Critical (>=100)\", fillcolor=\"#FF6B6B\", fontcolor=\"white\"];")?;
writeln!(
out,
" legend_high [label=\"High (>=50)\", fillcolor=\"#FF8C00\"];"
)?;
writeln!(
out,
" legend_medium [label=\"Medium (>=20)\", fillcolor=\"#FFD93D\"];"
)?;
writeln!(
out,
" legend_low [label=\"Low (<20)\", fillcolor=\"#6BCB77\"];"
)?;
writeln!(
out,
" legend_critical -> legend_high -> legend_medium -> legend_low [style=invis];"
)?;
writeln!(out, " }}")?;
Ok(())
}
fn group_by_module<'a>(
&self,
items: &'a [FileDebtItemOutput],
) -> HashMap<String, Vec<&'a FileDebtItemOutput>> {
let mut modules: HashMap<String, Vec<&'a FileDebtItemOutput>> = HashMap::new();
for item in items {
let module = extract_module(&item.location);
modules.entry(module).or_default().push(item);
}
modules
}
fn write_cluster<W: Write>(
&self,
out: &mut W,
module: &str,
items: &[&FileDebtItemOutput],
) -> io::Result<()> {
let cluster_id = sanitize_id(module);
writeln!(out, " subgraph cluster_{} {{", cluster_id)?;
writeln!(out, " label=\"{}\";", escape_label(module))?;
writeln!(out, " style=rounded;")?;
writeln!(out, " bgcolor=\"#F5F5F5\";")?;
writeln!(out, " fontname=\"Helvetica\";")?;
writeln!(out)?;
for item in items {
self.write_node(out, item, " ")?;
}
writeln!(out, " }}")?;
Ok(())
}
fn write_node<W: Write>(
&self,
out: &mut W,
item: &FileDebtItemOutput,
indent: &str,
) -> io::Result<()> {
let id = path_to_id(&item.location.file);
let label = path_to_label(&item.location.file);
let color = score_to_color(item.score);
let font_color = score_to_font_color(item.score);
let tooltip = format!(
"{}\\nScore: {:.1}\\nFunctions: {}\\nLines: {}",
item.location.file, item.score, item.metrics.functions, item.metrics.lines
);
let tooltip = if let Some(deps) = &item.dependencies {
format!(
"{}\\nCoupling: {} (Ca={}, Ce={})\\nInstability: {:.2}",
tooltip,
deps.coupling_classification,
deps.afferent_coupling,
deps.efferent_coupling,
deps.instability
)
} else {
tooltip
};
writeln!(
out,
"{}\"{}\" [label=\"{}\", fillcolor=\"{}\", fontcolor=\"{}\", tooltip=\"{}\"];",
indent, id, label, color, font_color, tooltip
)?;
Ok(())
}
fn write_edges<W: Write>(
&self,
out: &mut W,
graph: &HashMap<String, Vec<String>>,
) -> io::Result<()> {
writeln!(out, " // Dependencies")?;
for (from_file, to_files) in graph {
let from_id = path_to_id(from_file);
for to_file in to_files {
let to_id = path_to_id(to_file);
writeln!(out, " \"{}\" -> \"{}\";", from_id, to_id)?;
}
}
Ok(())
}
}
impl Default for DotWriter {
fn default() -> Self {
Self::new()
}
}
fn path_to_id(path: &str) -> String {
path.replace(['/', '\\', '.', '-', ' '], "_")
}
fn path_to_label(path: &str) -> String {
std::path::Path::new(path)
.file_name()
.and_then(|f| f.to_str())
.unwrap_or(path)
.to_string()
}
fn extract_module(location: &UnifiedLocation) -> String {
let path = std::path::Path::new(&location.file);
path.parent()
.and_then(|p| p.to_str())
.map(|s| {
let s = s.strip_prefix("./").unwrap_or(s);
let s = s.strip_prefix("src/").unwrap_or(s);
let s = if s == "src" { "" } else { s };
if s.is_empty() {
"root".to_string()
} else {
s.to_string()
}
})
.unwrap_or_else(|| "root".to_string())
}
fn sanitize_id(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect()
}
fn escape_label(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
}
fn score_to_color(score: f64) -> &'static str {
if score >= 100.0 {
"#FF6B6B" } else if score >= 50.0 {
"#FF8C00" } else if score >= 20.0 {
"#FFD93D" } else {
"#6BCB77" }
}
fn score_to_font_color(score: f64) -> &'static str {
if score >= 100.0 {
"white" } else {
"black" }
}
#[allow(dead_code)]
fn coupling_to_edge_style(classification: &CouplingClassification) -> &'static str {
match classification {
CouplingClassification::StableCore => "bold",
CouplingClassification::HighlyCoupled => "dashed",
CouplingClassification::Isolated => "dotted",
_ => "solid",
}
}
pub fn render_dot(analysis: &UnifiedAnalysis, config: DotConfig) -> io::Result<String> {
let mut buffer = Vec::new();
let writer = DotWriter::with_config(config);
writer.write(analysis, &mut buffer)?;
String::from_utf8(buffer).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_path_to_id() {
assert_eq!(path_to_id("src/main.rs"), "src_main_rs");
assert_eq!(path_to_id("foo/bar-baz.rs"), "foo_bar_baz_rs");
assert_eq!(path_to_id("simple.rs"), "simple_rs");
}
#[test]
fn test_path_to_label() {
assert_eq!(path_to_label("src/main.rs"), "main.rs");
assert_eq!(path_to_label("foo/bar/baz.rs"), "baz.rs");
assert_eq!(path_to_label("simple.rs"), "simple.rs");
}
#[test]
fn test_extract_module() {
let loc = UnifiedLocation {
file: "src/io/writers/dot.rs".to_string(),
line: None,
function: None,
file_context_label: None,
};
assert_eq!(extract_module(&loc), "io/writers");
let loc = UnifiedLocation {
file: "./src/main.rs".to_string(),
line: None,
function: None,
file_context_label: None,
};
assert_eq!(extract_module(&loc), "root");
}
#[test]
fn test_sanitize_id() {
assert_eq!(sanitize_id("foo/bar"), "foo_bar");
assert_eq!(sanitize_id("hello-world"), "hello_world");
assert_eq!(sanitize_id("test_123"), "test_123");
}
#[test]
fn test_escape_label() {
assert_eq!(escape_label("foo\"bar"), "foo\\\"bar");
assert_eq!(escape_label("foo\\bar"), "foo\\\\bar");
assert_eq!(escape_label("foo\nbar"), "foo\\nbar");
}
#[test]
fn test_score_to_color() {
assert_eq!(score_to_color(150.0), "#FF6B6B"); assert_eq!(score_to_color(100.0), "#FF6B6B"); assert_eq!(score_to_color(75.0), "#FF8C00"); assert_eq!(score_to_color(50.0), "#FF8C00"); assert_eq!(score_to_color(35.0), "#FFD93D"); assert_eq!(score_to_color(20.0), "#FFD93D"); assert_eq!(score_to_color(10.0), "#6BCB77"); }
#[test]
fn test_score_to_font_color() {
assert_eq!(score_to_font_color(100.0), "white"); assert_eq!(score_to_font_color(50.0), "black"); }
#[test]
fn test_rankdir_as_str() {
assert_eq!(RankDir::TopBottom.as_str(), "TB");
assert_eq!(RankDir::LeftRight.as_str(), "LR");
}
#[test]
fn test_dot_config_default() {
let config = DotConfig::default();
assert!(config.min_score.is_none());
assert!(config.max_depth.is_none());
assert!(!config.include_external);
assert!(config.cluster_by_module);
assert!(matches!(config.rankdir, RankDir::TopBottom));
}
#[test]
fn test_write_legend() {
let writer = DotWriter::new();
let mut buffer = Vec::new();
writer.write_legend(&mut buffer).unwrap();
let output = String::from_utf8(buffer).unwrap();
assert!(output.contains("cluster_legend"));
assert!(output.contains("Debt Score Legend"));
assert!(output.contains("Critical (>=100)"));
assert!(output.contains("High (>=50)"));
assert!(output.contains("Medium (>=20)"));
assert!(output.contains("Low (<20)"));
assert!(output.contains("#FF6B6B")); assert!(output.contains("#6BCB77")); }
#[test]
fn test_coupling_to_edge_style() {
assert_eq!(
coupling_to_edge_style(&CouplingClassification::StableCore),
"bold"
);
assert_eq!(
coupling_to_edge_style(&CouplingClassification::HighlyCoupled),
"dashed"
);
assert_eq!(
coupling_to_edge_style(&CouplingClassification::Isolated),
"dotted"
);
assert_eq!(
coupling_to_edge_style(&CouplingClassification::UtilityModule),
"solid"
);
assert_eq!(
coupling_to_edge_style(&CouplingClassification::LeafModule),
"solid"
);
}
}