fabro/lib/crates/arc-workflows/src/cli/graph.rs
Bryan Helmkamp 55917f4005 Add arc graph subcommand to render workflow DOT graphs as SVG/PNG
Mirrors `arc validate` workflow resolution. Shells out to `dot` (graphviz)
with friendly error if not installed. Supports --format svg|png and -o output path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-11 10:34:23 -04:00

281 lines
8.3 KiB
Rust

use std::fmt;
use std::io::Write;
use std::path::PathBuf;
use std::process::Command;
use anyhow::bail;
use arc_util::terminal::Styles;
use clap::{Args, ValueEnum};
use tracing::debug;
use crate::validation::Severity;
use crate::workflow::prepare_from_file;
use super::{print_diagnostics, read_dot_file, relative_path};
/// Output format for graph rendering.
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
pub enum GraphFormat {
/// Scalable Vector Graphics
#[default]
Svg,
/// Portable Network Graphics
Png,
}
impl fmt::Display for GraphFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Svg => write!(f, "svg"),
Self::Png => write!(f, "png"),
}
}
}
#[derive(Args)]
pub struct GraphArgs {
/// Path to the .dot workflow file, .toml task config, or project workflow name
pub workflow: PathBuf,
/// Output format
#[arg(long, value_enum, default_value_t = GraphFormat::Svg)]
pub format: GraphFormat,
/// Output file path (defaults to stdout)
#[arg(short, long)]
pub output: Option<PathBuf>,
}
/// Render a workflow graph to SVG or PNG.
pub fn graph_command(args: &GraphArgs, styles: &Styles) -> anyhow::Result<()> {
let (dot_path, _cfg) = super::project_config::resolve_workflow(&args.workflow)?;
let (_graph, diagnostics) = prepare_from_file(&dot_path)?;
print_diagnostics(&diagnostics, styles);
if diagnostics.iter().any(|d| d.severity == Severity::Error) {
bail!("Validation failed");
}
let source = read_dot_file(&dot_path)?;
let rendered = render_dot(&source, args.format)?;
if let Some(ref output_path) = args.output {
std::fs::write(output_path, &rendered)?;
} else {
std::io::stdout().write_all(&rendered)?;
}
debug!(
path = %relative_path(&dot_path),
format = %args.format,
"Rendered workflow graph"
);
Ok(())
}
/// Spawn the `dot` command to render DOT source into the given format.
fn render_dot(source: &str, format: GraphFormat) -> anyhow::Result<Vec<u8>> {
let mut child = match Command::new("dot")
.arg(format!("-T{format}"))
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
bail!("Graphviz is not installed. Install it with: brew install graphviz");
}
Err(err) => {
bail!("Failed to run dot: {err}");
}
};
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(source.as_bytes())?;
}
let output = child.wait_with_output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("dot failed: {stderr}");
}
Ok(output.stdout)
}
/// Check whether the `dot` command is available on PATH.
#[cfg(test)]
fn dot_is_available() -> bool {
Command::new("dot")
.arg("-V")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
const VALID_DOT: &str = r#"digraph Simple {
graph [goal="Run tests and report results"]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
run_tests [label="Run Tests", prompt="Run the test suite and report results"]
report [label="Report", prompt="Summarize the test results"]
start -> run_tests -> report -> exit
}"#;
#[test]
fn graph_missing_file() {
let args = GraphArgs {
workflow: PathBuf::from("/tmp/nonexistent_workflow_99999.dot"),
format: GraphFormat::Svg,
output: None,
};
let styles = Styles::new(false);
let result = graph_command(&args, &styles);
assert!(result.is_err(), "expected Err for missing file");
}
#[test]
fn graph_invalid_syntax() {
let mut tmp = tempfile::Builder::new().suffix(".dot").tempfile().unwrap();
write!(tmp, "not a valid dot file").unwrap();
let args = GraphArgs {
workflow: tmp.path().to_path_buf(),
format: GraphFormat::Svg,
output: None,
};
let styles = Styles::new(false);
let result = graph_command(&args, &styles);
assert!(result.is_err(), "expected Err for invalid syntax");
}
#[test]
fn graph_valid_workflow_svg() {
if !dot_is_available() {
eprintln!("skipping: graphviz not installed");
return;
}
let mut tmp = tempfile::Builder::new().suffix(".dot").tempfile().unwrap();
write!(tmp, "{VALID_DOT}").unwrap();
let output_dir = tempfile::tempdir().unwrap();
let output_path = output_dir.path().join("out.svg");
let args = GraphArgs {
workflow: tmp.path().to_path_buf(),
format: GraphFormat::Svg,
output: Some(output_path.clone()),
};
let styles = Styles::new(false);
let result = graph_command(&args, &styles);
assert!(result.is_ok(), "expected Ok but got: {result:?}");
let content = std::fs::read_to_string(&output_path).unwrap();
assert!(content.contains("<svg"), "expected SVG content");
}
#[test]
fn graph_valid_workflow_png() {
if !dot_is_available() {
eprintln!("skipping: graphviz not installed");
return;
}
let mut tmp = tempfile::Builder::new().suffix(".dot").tempfile().unwrap();
write!(tmp, "{VALID_DOT}").unwrap();
let output_dir = tempfile::tempdir().unwrap();
let output_path = output_dir.path().join("out.png");
let args = GraphArgs {
workflow: tmp.path().to_path_buf(),
format: GraphFormat::Png,
output: Some(output_path.clone()),
};
let styles = Styles::new(false);
let result = graph_command(&args, &styles);
assert!(result.is_ok(), "expected Ok but got: {result:?}");
let bytes = std::fs::read(&output_path).unwrap();
// PNG magic bytes: 0x89 P N G
assert!(
bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]),
"expected PNG magic bytes"
);
}
#[test]
fn graph_output_to_file() {
if !dot_is_available() {
eprintln!("skipping: graphviz not installed");
return;
}
let mut tmp = tempfile::Builder::new().suffix(".dot").tempfile().unwrap();
write!(tmp, "{VALID_DOT}").unwrap();
let output_dir = tempfile::tempdir().unwrap();
let output_path = output_dir.path().join("result.svg");
let args = GraphArgs {
workflow: tmp.path().to_path_buf(),
format: GraphFormat::Svg,
output: Some(output_path.clone()),
};
let styles = Styles::new(false);
graph_command(&args, &styles).unwrap();
assert!(output_path.exists(), "output file should exist");
let content = std::fs::read_to_string(&output_path).unwrap();
assert!(!content.is_empty(), "output file should not be empty");
}
#[test]
fn graph_toml_path() {
if !dot_is_available() {
eprintln!("skipping: graphviz not installed");
return;
}
let tmp = tempfile::tempdir().unwrap();
let wf_dir = tmp.path().join("workflows").join("hello");
std::fs::create_dir_all(&wf_dir).unwrap();
std::fs::write(
wf_dir.join("workflow.toml"),
"version = 1\ngraph = \"workflow.dot\"\n",
)
.unwrap();
std::fs::write(wf_dir.join("workflow.dot"), VALID_DOT).unwrap();
let output_dir = tempfile::tempdir().unwrap();
let output_path = output_dir.path().join("out.svg");
let args = GraphArgs {
workflow: wf_dir.join("workflow.toml"),
format: GraphFormat::Svg,
output: Some(output_path.clone()),
};
let styles = Styles::new(false);
let result = graph_command(&args, &styles);
assert!(result.is_ok(), "expected Ok but got: {result:?}");
let content = std::fs::read_to_string(&output_path).unwrap();
assert!(content.contains("<svg"), "expected SVG content");
}
}