use std::io::Write;
use camino::Utf8Path;
use super::render::{GraphRenderError, GraphRenderer};
use super::{EdgeClass, GraphView, NodeKind};
#[derive(Debug, Default, Clone, Copy)]
pub struct DotRenderer;
impl DotRenderer {
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl GraphRenderer for DotRenderer {
fn render(&self, view: &GraphView, sink: &mut dyn Write) -> Result<(), GraphRenderError> {
writeln!(sink, "digraph netsuke {{")?;
writeln!(sink, " rankdir=\"LR\"")?;
writeln!(sink, " node [shape=box, fontsize=10]")?;
writeln!(sink, " edge [fontsize=10]")?;
for node in &view.nodes {
write_node(
sink,
node.path.as_path(),
node.kind,
node.description.as_deref(),
)?;
}
for edge in &view.edges {
write_edge(sink, edge.from.as_path(), edge.to.as_path(), edge.class)?;
}
if !view.default_targets.is_empty() {
writeln!(sink, " // default targets:")?;
for target in &view.default_targets {
writeln!(sink, " // - {}", escape_dot(target.as_str()))?;
}
}
writeln!(sink, "}}")?;
Ok(())
}
}
fn write_node(
sink: &mut dyn Write,
path: &Utf8Path,
kind: NodeKind,
description: Option<&str>,
) -> Result<(), GraphRenderError> {
let path_str = escape_dot(path.as_str());
let label = description.filter(|s| !s.is_empty()).map_or_else(
|| path_str.clone(),
|d| format!("{}\\n{}", path_str, escape_dot(d)),
);
let attrs = match kind {
NodeKind::Source => format!("label=\"{label}\""),
NodeKind::Target { phony: true, .. } => {
format!("label=\"{label}\", style=dashed, color=\"#666666\"")
}
NodeKind::Target {
phony: false,
always: true,
} => format!("label=\"{label}\", color=\"#cc6600\""),
NodeKind::Target {
phony: false,
always: false,
} => format!("label=\"{label}\", color=\"#003366\""),
};
writeln!(sink, " \"{path_str}\" [{attrs}]")?;
Ok(())
}
fn write_edge(
sink: &mut dyn Write,
from: &Utf8Path,
to: &Utf8Path,
class: EdgeClass,
) -> Result<(), GraphRenderError> {
let from_str = escape_dot(from.as_str());
let to_str = escape_dot(to.as_str());
let attrs = match class {
EdgeClass::Explicit => "",
EdgeClass::ImplicitDep => " [style=bold]",
EdgeClass::ImplicitOutput => " [style=dotted]",
EdgeClass::OrderOnly => " [style=dashed]",
};
writeln!(sink, " \"{from_str}\" -> \"{to_str}\"{attrs}")?;
Ok(())
}
fn escape_dot(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use anyhow::{Context, Result, ensure};
use camino::Utf8PathBuf;
use super::*;
use crate::graph_view::{EdgeView, GraphView, NodeView};
fn empty_view() -> GraphView {
GraphView {
default_targets: Vec::new(),
nodes: Vec::new(),
edges: Vec::new(),
limit: None,
}
}
fn render(view: &GraphView) -> Result<String> {
let mut out = Vec::new();
DotRenderer::new()
.render(view, &mut out)
.context("render dot")?;
String::from_utf8(out).context("utf-8")
}
#[test]
fn empty_graph_produces_well_formed_digraph() -> Result<()> {
let dot = render(&empty_view())?;
ensure!(
dot.starts_with("digraph netsuke {"),
"dot output should start with the digraph header"
);
ensure!(
dot.trim_end().ends_with('}'),
"dot output should end with a closing brace"
);
Ok(())
}
#[test]
fn renders_source_target_and_explicit_edge() -> Result<()> {
let view = GraphView {
default_targets: vec![Utf8PathBuf::from("out/a.o")],
nodes: vec![
NodeView {
path: Utf8PathBuf::from("out/a.o"),
kind: NodeKind::Target {
phony: false,
always: false,
},
action_id: Some("h".into()),
description: Some("compile a".into()),
},
NodeView {
path: Utf8PathBuf::from("src/a.c"),
kind: NodeKind::Source,
action_id: None,
description: None,
},
],
edges: vec![EdgeView {
from: Utf8PathBuf::from("src/a.c"),
to: Utf8PathBuf::from("out/a.o"),
class: EdgeClass::Explicit,
}],
limit: None,
};
let dot = render(&view)?;
ensure!(
dot.contains("\"src/a.c\" [label=\"src/a.c\"]"),
"missing source node label"
);
ensure!(
dot.contains("\"out/a.o\" [label=\"out/a.o\\ncompile a\""),
"missing target node label with description"
);
ensure!(
dot.contains("\"src/a.c\" -> \"out/a.o\"\n"),
"missing explicit edge"
);
ensure!(
dot.contains("// default targets:"),
"missing default targets comment"
);
ensure!(dot.contains("// - out/a.o"), "missing default target entry");
Ok(())
}
#[test]
fn order_only_and_implicit_edges_carry_style() -> Result<()> {
let view = GraphView {
default_targets: Vec::new(),
nodes: vec![NodeView {
path: Utf8PathBuf::from("o"),
kind: NodeKind::Source,
action_id: None,
description: None,
}],
edges: vec![
EdgeView {
from: Utf8PathBuf::from("a"),
to: Utf8PathBuf::from("o"),
class: EdgeClass::OrderOnly,
},
EdgeView {
from: Utf8PathBuf::from("b"),
to: Utf8PathBuf::from("o"),
class: EdgeClass::ImplicitOutput,
},
],
limit: None,
};
let dot = render(&view)?;
ensure!(
dot.contains("\"a\" -> \"o\" [style=dashed]"),
"order-only edge should be dashed"
);
ensure!(
dot.contains("\"b\" -> \"o\" [style=dotted]"),
"implicit output edge should be dotted"
);
Ok(())
}
#[test]
fn implicit_dep_edges_are_bold() -> Result<()> {
let view = GraphView {
default_targets: Vec::new(),
nodes: vec![NodeView {
path: Utf8PathBuf::from("o"),
kind: NodeKind::Source,
action_id: None,
description: None,
}],
edges: vec![EdgeView {
from: Utf8PathBuf::from("dep"),
to: Utf8PathBuf::from("o"),
class: EdgeClass::ImplicitDep,
}],
limit: None,
};
let dot = render(&view)?;
ensure!(
dot.contains("\"dep\" -> \"o\" [style=bold]"),
"implicit dependency edge should be bold"
);
Ok(())
}
#[test]
fn paths_with_quotes_are_escaped() -> Result<()> {
let view = GraphView {
default_targets: Vec::new(),
nodes: vec![NodeView {
path: Utf8PathBuf::from("a\"b"),
kind: NodeKind::Source,
action_id: None,
description: None,
}],
edges: Vec::new(),
limit: None,
};
let dot = render(&view)?;
ensure!(
dot.contains("\"a\\\"b\""),
"quoted path should be escaped in dot output"
);
Ok(())
}
}