use std::collections::BTreeMap;
use crate::check::{Edge, RuleId, Violation};
use crate::config::Config;
#[derive(Clone, Copy, clap::ValueEnum)]
pub enum GraphFormat {
Mermaid,
Dot,
}
struct Arrow {
imports: usize,
violating: bool,
}
fn arrows(edges: &[Edge], violations: &[Violation]) -> BTreeMap<(String, String), Arrow> {
let mut arrows: BTreeMap<(String, String), Arrow> = BTreeMap::new();
for e in edges {
arrows
.entry((e.from.clone(), e.to.clone()))
.or_insert(Arrow {
imports: 0,
violating: false,
})
.imports += 1;
}
for v in violations {
if matches!(v.rule, RuleId::Dependency | RuleId::Privacy)
&& let Some(arrow) = arrows.get_mut(&(v.from.clone(), v.to.clone()))
{
arrow.violating = true;
}
}
arrows
}
pub fn render(
format: GraphFormat,
config: &Config,
edges: &[Edge],
violations: &[Violation],
) -> String {
let arrows = arrows(edges, violations);
match format {
GraphFormat::Mermaid => mermaid(config, &arrows),
GraphFormat::Dot => dot(config, &arrows),
}
}
fn mermaid(config: &Config, arrows: &BTreeMap<(String, String), Arrow>) -> String {
let ids: BTreeMap<&str, String> = config
.components
.keys()
.enumerate()
.map(|(i, name)| (name.as_str(), format!("c{i}")))
.collect();
let mut out = String::from("graph LR\n");
for (name, id) in &ids {
out.push_str(&format!(" {id}[\"{}\"]\n", name.replace('"', "#quot;")));
}
let mut red = Vec::new();
for (i, ((from, to), arrow)) in arrows.iter().enumerate() {
let (from, to) = (&ids[from.as_str()], &ids[to.as_str()]);
if arrow.violating {
out.push_str(&format!(" {from} -.->|{} ✗| {to}\n", arrow.imports));
red.push(i.to_string());
} else {
out.push_str(&format!(" {from} -->|{}| {to}\n", arrow.imports));
}
}
if !red.is_empty() {
out.push_str(&format!(
" linkStyle {} stroke:#d00,stroke-width:2px,color:#d00\n",
red.join(",")
));
}
out
}
fn dot(config: &Config, arrows: &BTreeMap<(String, String), Arrow>) -> String {
let quote = |s: &str| format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""));
let mut out = String::from("digraph easement {\n rankdir=LR;\n node [shape=box];\n");
for name in config.components.keys() {
out.push_str(&format!(" {};\n", quote(name)));
}
for ((from, to), arrow) in arrows {
let style = if arrow.violating {
", color=red, fontcolor=red, style=dashed"
} else {
""
};
out.push_str(&format!(
" {} -> {} [label=\"{}\"{style}];\n",
quote(from),
quote(to),
arrow.imports
));
}
out.push_str("}\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> (Config, Vec<Edge>, Vec<Violation>) {
let config: Config = serde_norway::from_str(
"version: 1
components:
a: { in: a }
b: { in: b }
lone: { in: lone }
",
)
.unwrap();
let edge = |from: &str, to: &str, line| Edge {
from: from.into(),
to: to.into(),
file: format!("{from}/x"),
line,
};
let edges = vec![edge("a", "b", 1), edge("a", "b", 2), edge("b", "a", 1)];
let violation = Violation {
rule: RuleId::Dependency,
file: "b/x".into(),
line: 1,
message: String::new(),
because: None,
from: "b".into(),
to: "a".into(),
target: String::new(),
specifier: String::new(),
evidence: Vec::new(),
};
(config, edges, vec![violation])
}
#[test]
fn mermaid_marks_violations() {
let (config, edges, violations) = fixture();
let out = render(GraphFormat::Mermaid, &config, &edges, &violations);
assert_eq!(
out,
"graph LR\n c0[\"a\"]\n c1[\"b\"]\n c2[\"lone\"]\n c0 -->|2| c1\n c1 -.->|1 ✗| c0\n linkStyle 1 stroke:#d00,stroke-width:2px,color:#d00\n"
);
}
#[test]
fn dot_marks_violations() {
let (config, edges, violations) = fixture();
let out = render(GraphFormat::Dot, &config, &edges, &violations);
assert!(out.contains("\"a\" -> \"b\" [label=\"2\"];"));
assert!(
out.contains("\"b\" -> \"a\" [label=\"1\", color=red, fontcolor=red, style=dashed];")
);
assert!(out.contains(" \"lone\";"));
}
}