mod ffi;
use std::ffi::{CStr, CString};
use std::marker::PhantomData;
use std::ptr;
#[derive(Debug, thiserror::Error)]
pub enum GraphvizError {
#[error("failed to create graphviz context")]
ContextCreationFailed,
#[error("null input provided to graphviz")]
NullInput,
#[error("invalid DOT input")]
InvalidDot,
#[error("layout computation failed")]
LayoutFailed,
#[error("render failed")]
RenderFailed,
#[error("invalid layout engine")]
InvalidEngine,
#[error("invalid output format")]
InvalidFormat,
#[error("out of memory")]
OutOfMemory,
#[error("context not initialized")]
NotInitialized,
#[error("DOT string contains interior NUL byte: {0}")]
NulByteInInput(#[from] std::ffi::NulError),
#[error("unknown graphviz error (code {0})")]
Unknown(i32),
}
impl GraphvizError {
fn from_code(code: ffi::gv_error_t) -> Self {
match code {
ffi::GV_ERR_NULL_INPUT => Self::NullInput,
ffi::GV_ERR_INVALID_DOT => Self::InvalidDot,
ffi::GV_ERR_LAYOUT_FAILED => Self::LayoutFailed,
ffi::GV_ERR_RENDER_FAILED => Self::RenderFailed,
ffi::GV_ERR_INVALID_ENGINE => Self::InvalidEngine,
ffi::GV_ERR_INVALID_FORMAT => Self::InvalidFormat,
ffi::GV_ERR_OUT_OF_MEMORY => Self::OutOfMemory,
ffi::GV_ERR_NOT_INITIALIZED => Self::NotInitialized,
other => Self::Unknown(other),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Engine {
Dot,
Neato,
Fdp,
Sfdp,
Circo,
Twopi,
Osage,
Patchwork,
}
impl Engine {
fn as_cstr(&self) -> &'static CStr {
match self {
Self::Dot => unsafe { CStr::from_bytes_with_nul_unchecked(b"dot\0") },
Self::Neato => unsafe { CStr::from_bytes_with_nul_unchecked(b"neato\0") },
Self::Fdp => unsafe { CStr::from_bytes_with_nul_unchecked(b"fdp\0") },
Self::Sfdp => unsafe { CStr::from_bytes_with_nul_unchecked(b"sfdp\0") },
Self::Circo => unsafe { CStr::from_bytes_with_nul_unchecked(b"circo\0") },
Self::Twopi => unsafe { CStr::from_bytes_with_nul_unchecked(b"twopi\0") },
Self::Osage => unsafe { CStr::from_bytes_with_nul_unchecked(b"osage\0") },
Self::Patchwork => unsafe { CStr::from_bytes_with_nul_unchecked(b"patchwork\0") },
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Format {
Svg,
Png,
Pdf,
Ps,
Json,
DotOutput,
Xdot,
Plain,
}
impl Format {
fn as_cstr(&self) -> &'static CStr {
match self {
Self::Svg => unsafe { CStr::from_bytes_with_nul_unchecked(b"svg\0") },
Self::Png => unsafe { CStr::from_bytes_with_nul_unchecked(b"png\0") },
Self::Pdf => unsafe { CStr::from_bytes_with_nul_unchecked(b"pdf\0") },
Self::Ps => unsafe { CStr::from_bytes_with_nul_unchecked(b"ps\0") },
Self::Json => unsafe { CStr::from_bytes_with_nul_unchecked(b"json\0") },
Self::DotOutput => unsafe { CStr::from_bytes_with_nul_unchecked(b"dot\0") },
Self::Xdot => unsafe { CStr::from_bytes_with_nul_unchecked(b"xdot\0") },
Self::Plain => unsafe { CStr::from_bytes_with_nul_unchecked(b"plain\0") },
}
}
}
pub struct GraphvizContext {
raw: *mut ffi::gv_context_t,
_not_send_sync: PhantomData<*mut ()>,
}
impl GraphvizContext {
pub fn new() -> Result<Self, GraphvizError> {
let raw = unsafe { ffi::gv_context_new() };
if raw.is_null() {
return Err(GraphvizError::ContextCreationFailed);
}
Ok(Self {
raw,
_not_send_sync: PhantomData,
})
}
pub fn render(
&self,
dot: &str,
engine: Engine,
format: Format,
) -> Result<Vec<u8>, GraphvizError> {
let c_dot = CString::new(dot)?;
let c_engine = engine.as_cstr();
let c_format = format.as_cstr();
let mut out_data: *mut std::os::raw::c_char = ptr::null_mut();
let mut out_len: usize = 0;
let rc = unsafe {
ffi::gv_render(
self.raw,
c_dot.as_ptr(),
c_engine.as_ptr(),
c_format.as_ptr(),
&mut out_data,
&mut out_len,
)
};
if rc != ffi::GV_OK {
return Err(GraphvizError::from_code(rc));
}
let bytes = if out_data.is_null() || out_len == 0 {
Vec::new()
} else {
let slice = unsafe { std::slice::from_raw_parts(out_data as *const u8, out_len) };
slice.to_vec()
};
if !out_data.is_null() {
unsafe { ffi::gv_free_render_data(out_data) };
}
Ok(bytes)
}
pub fn render_to_string(
&self,
dot: &str,
engine: Engine,
format: Format,
) -> Result<String, GraphvizError> {
let bytes = self.render(dot, engine, format)?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
}
impl Drop for GraphvizContext {
fn drop(&mut self) {
if !self.raw.is_null() {
unsafe { ffi::gv_context_free(self.raw) };
}
}
}
pub fn version() -> Option<String> {
let ptr = unsafe { ffi::gv_version() };
if ptr.is_null() {
return None;
}
let cstr = unsafe { CStr::from_ptr(ptr) };
Some(cstr.to_string_lossy().into_owned())
}
pub fn strerror(code: i32) -> Option<String> {
let ptr = unsafe { ffi::gv_strerror(code) };
if ptr.is_null() {
return None;
}
let cstr = unsafe { CStr::from_ptr(ptr) };
Some(cstr.to_string_lossy().into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn engine_cstr_no_panic() {
let engines = [
Engine::Dot,
Engine::Neato,
Engine::Fdp,
Engine::Sfdp,
Engine::Circo,
Engine::Twopi,
Engine::Osage,
Engine::Patchwork,
];
for e in &engines {
let s = e.as_cstr();
assert!(!s.to_bytes().is_empty());
}
}
#[test]
fn engine_names_are_correct() {
assert_eq!(Engine::Dot.as_cstr().to_str().unwrap(), "dot");
assert_eq!(Engine::Neato.as_cstr().to_str().unwrap(), "neato");
assert_eq!(Engine::Fdp.as_cstr().to_str().unwrap(), "fdp");
assert_eq!(Engine::Sfdp.as_cstr().to_str().unwrap(), "sfdp");
assert_eq!(Engine::Circo.as_cstr().to_str().unwrap(), "circo");
assert_eq!(Engine::Twopi.as_cstr().to_str().unwrap(), "twopi");
assert_eq!(Engine::Osage.as_cstr().to_str().unwrap(), "osage");
assert_eq!(Engine::Patchwork.as_cstr().to_str().unwrap(), "patchwork");
}
#[test]
fn engine_equality() {
assert_eq!(Engine::Dot, Engine::Dot);
assert_ne!(Engine::Dot, Engine::Neato);
}
#[test]
fn engine_hash() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(Engine::Dot);
set.insert(Engine::Neato);
set.insert(Engine::Dot); assert_eq!(set.len(), 2); }
#[test]
fn format_cstr_no_panic() {
let formats = [
Format::Svg,
Format::Png,
Format::Pdf,
Format::Ps,
Format::Json,
Format::DotOutput,
Format::Xdot,
Format::Plain,
];
for f in &formats {
let s = f.as_cstr();
assert!(!s.to_bytes().is_empty());
}
}
#[test]
fn format_names_are_correct() {
assert_eq!(Format::Svg.as_cstr().to_str().unwrap(), "svg");
assert_eq!(Format::Png.as_cstr().to_str().unwrap(), "png");
assert_eq!(Format::Pdf.as_cstr().to_str().unwrap(), "pdf");
assert_eq!(Format::Ps.as_cstr().to_str().unwrap(), "ps");
assert_eq!(Format::Json.as_cstr().to_str().unwrap(), "json");
assert_eq!(Format::DotOutput.as_cstr().to_str().unwrap(), "dot");
assert_eq!(Format::Xdot.as_cstr().to_str().unwrap(), "xdot");
assert_eq!(Format::Plain.as_cstr().to_str().unwrap(), "plain");
}
#[test]
fn format_equality() {
assert_eq!(Format::Svg, Format::Svg);
assert_ne!(Format::Svg, Format::Png);
}
#[test]
fn format_hash() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(Format::Svg);
set.insert(Format::Png);
set.insert(Format::Svg); assert_eq!(set.len(), 2); }
#[test]
fn error_display() {
let err = GraphvizError::InvalidDot;
let msg = format!("{err}");
assert!(msg.contains("invalid DOT"), "got: {msg}");
}
#[test]
fn error_from_code_roundtrip() {
let err = GraphvizError::from_code(ffi::GV_ERR_OUT_OF_MEMORY);
assert!(matches!(err, GraphvizError::OutOfMemory));
}
#[test]
fn all_error_codes_mapped() {
assert!(matches!(
GraphvizError::from_code(ffi::GV_ERR_NULL_INPUT),
GraphvizError::NullInput
));
assert!(matches!(
GraphvizError::from_code(ffi::GV_ERR_INVALID_DOT),
GraphvizError::InvalidDot
));
assert!(matches!(
GraphvizError::from_code(ffi::GV_ERR_LAYOUT_FAILED),
GraphvizError::LayoutFailed
));
assert!(matches!(
GraphvizError::from_code(ffi::GV_ERR_RENDER_FAILED),
GraphvizError::RenderFailed
));
assert!(matches!(
GraphvizError::from_code(ffi::GV_ERR_INVALID_ENGINE),
GraphvizError::InvalidEngine
));
assert!(matches!(
GraphvizError::from_code(ffi::GV_ERR_INVALID_FORMAT),
GraphvizError::InvalidFormat
));
assert!(matches!(
GraphvizError::from_code(ffi::GV_ERR_OUT_OF_MEMORY),
GraphvizError::OutOfMemory
));
assert!(matches!(
GraphvizError::from_code(ffi::GV_ERR_NOT_INITIALIZED),
GraphvizError::NotInitialized
));
}
#[test]
fn error_unknown_code() {
let err = GraphvizError::from_code(999);
assert!(matches!(err, GraphvizError::Unknown(999)));
}
#[test]
fn nul_byte_in_input_is_error() {
let result = CString::new("hello\0world");
assert!(result.is_err());
}
#[test]
fn error_nul_byte_error() {
let nul_err = CString::new("hello\0").unwrap_err();
let graphviz_err = GraphvizError::from(nul_err);
match graphviz_err {
GraphvizError::NulByteInInput(_) => {}
_ => panic!("Expected NulByteInInput"),
}
}
#[test]
fn error_has_std_error_impl() {
use std::error::Error;
let err: Box<dyn Error> = Box::new(GraphvizError::InvalidDot);
assert_eq!(err.to_string(), "invalid DOT input");
}
#[test]
fn context_is_not_send() {
let _ctx = GraphvizContext {
raw: std::ptr::null_mut(),
_not_send_sync: std::marker::PhantomData,
};
}
#[test]
fn context_creation_null_returns_error() {
let err = GraphvizError::ContextCreationFailed;
assert!(format!("{err}").contains("context"));
}
#[test]
fn render_error_from_code() {
let err = GraphvizError::from_code(ffi::GV_ERR_RENDER_FAILED);
assert!(matches!(err, GraphvizError::RenderFailed));
}
#[test]
fn layout_error_from_code() {
let err = GraphvizError::from_code(ffi::GV_ERR_LAYOUT_FAILED);
assert!(matches!(err, GraphvizError::LayoutFailed));
}
#[test]
fn engine_validation_error() {
let err = GraphvizError::from_code(ffi::GV_ERR_INVALID_ENGINE);
assert!(matches!(err, GraphvizError::InvalidEngine));
}
#[test]
fn format_validation_error() {
let err = GraphvizError::from_code(ffi::GV_ERR_INVALID_FORMAT);
assert!(matches!(err, GraphvizError::InvalidFormat));
}
#[test]
fn version_returns_string_or_none() {
let _result: Option<String> = version();
}
#[test]
fn strerror_returns_string_or_none() {
let _result: Option<String> = strerror(ffi::GV_ERR_INVALID_DOT);
}
#[test]
fn valid_dot_creates_cstring() {
let dot = "digraph { a -> b }";
let result = CString::new(dot);
assert!(result.is_ok());
assert_eq!(result.unwrap().to_str().unwrap(), dot);
}
#[test]
fn empty_dot_creates_cstring() {
let result = CString::new("");
assert!(result.is_ok());
}
#[test]
fn complex_dot_creates_cstring() {
let dot = r#"
digraph G {
rankdir=LR;
node [shape=record];
a [label="<left> A | <right> B"];
a -> b;
}
"#;
let result = CString::new(dot);
assert!(result.is_ok());
}
#[test]
fn engine_implements_debug() {
let engine = Engine::Dot;
let _debug_str = format!("{:?}", engine);
}
#[test]
fn engine_implements_clone_copy() {
let e1 = Engine::Dot;
let e2 = e1;
let e3 = e1.clone();
assert_eq!(e1, e2);
assert_eq!(e2, e3);
}
#[test]
fn format_implements_debug() {
let format = Format::Svg;
let _debug_str = format!("{:?}", format);
}
#[test]
fn format_implements_clone_copy() {
let f1 = Format::Svg;
let f2 = f1;
let f3 = f1.clone();
assert_eq!(f1, f2);
assert_eq!(f2, f3);
}
#[test]
fn error_implements_debug() {
let err = GraphvizError::InvalidDot;
let _debug_str = format!("{:?}", err);
}
}