rrd 0.3.0

Bindings to librrd to create and interact with round robin databases created with Tobias Oetiker's rrdtool (https://www.rrdtool.org/).
Documentation
//! Render graphs from RRD data.
//!
//! There are many options for graphs. See <https://oss.oetiker.ch/rrdtool/doc/rrdgraph.en.html> and
//! <https://oss.oetiker.ch/rrdtool/tut/rrdtutorial.en.html> for more detail.
pub mod elements;
pub mod props;

use crate::error::InvalidArgument;
use crate::TimestampExt;
use crate::{
    error::{get_rrd_error, RrdError, RrdResult},
    ops::{
        graph::{
            elements::GraphElement,
            props::{GraphProps, ImageFormat},
        },
        info::{self, InfoValue},
    },
    util::ArrayOfStrings,
    Timestamp,
};
use log::debug;
use nom::Parser;
use nom::{bytes, character::complete, combinator, sequence, Finish};
use std::{collections, ffi::CString, fmt::Write as _};

/// Returns a tuple containing the graph image data in the specified format and metadata about the
/// graph.
///
/// See <https://oss.oetiker.ch/rrdtool/doc/rrdgraph.en.html> or `/tests/tutorial.rs`.
///
/// # Errors
/// Returns an error if the graph cannot be generated or if any provided data is invalid.
pub fn graph(
    image_format: ImageFormat,
    props: &GraphProps,
    elements: &[GraphElement],
) -> RrdResult<(Vec<u8>, GraphMetadata)> {
    // Need to include initial "graphv" command since that's how `rrdtool` invokes rrd_graph_v.
    // Filename `-` means include image data in the return hash rather than writing to a file
    let mut args = vec!["graphv".to_string(), "-".to_string()];
    args.extend(graph_args(Some(image_format), props, elements)?);

    debug!("Graph: args={args:?}");
    let args = args
        .into_iter()
        .map(CString::new)
        .collect::<Result<ArrayOfStrings, _>>()?;

    let argc = args.len().try_into().map_err(|_| {
        RrdError::InvalidArgument("too many graph arguments for librrd".to_string())
    })?;

    let info_ptr = unsafe {
        rrd_sys::rrd_graph_v(
            argc,
            // different librrd versions differ in mutability of this pointer
            args.as_ptr().cast(),
        )
    };
    if info_ptr.is_null() {
        return Err(get_rrd_error().unwrap_or_else(|| {
            RrdError::Internal("No graph data produced, but no librrd error".to_string())
        }));
    }

    let mut info = info::build_info_map(info_ptr)?;

    // pull out image first so debug output isn't massive
    let image = extract_info_value(&mut info, "image", info::InfoValue::into_blob)?;

    debug!("Graph output: {info:?}");

    let graph_left = extract_info_value(&mut info, "graph_left", info::InfoValue::into_count)?;
    let graph_top = extract_info_value(&mut info, "graph_top", info::InfoValue::into_count)?;
    let graph_width = extract_info_value(&mut info, "graph_width", info::InfoValue::into_count)?;
    let graph_height = extract_info_value(&mut info, "graph_height", info::InfoValue::into_count)?;
    let image_width = extract_info_value(&mut info, "image_width", info::InfoValue::into_count)?;
    let image_height = extract_info_value(&mut info, "image_height", info::InfoValue::into_count)?;
    let graph_start = timestamp_from_count(extract_info_value(
        &mut info,
        "graph_start",
        info::InfoValue::into_count,
    )?)?;
    let graph_end = timestamp_from_count(extract_info_value(
        &mut info,
        "graph_end",
        info::InfoValue::into_count,
    )?)?;
    let value_min = extract_info_value(&mut info, "value_min", info::InfoValue::into_value)?;
    let value_max = extract_info_value(&mut info, "value_max", info::InfoValue::into_value)?;

    Ok((
        image,
        GraphMetadata {
            graph_left,
            graph_top,
            graph_width,
            graph_height,
            graph_start,
            graph_end,
            image_width,
            image_height,
            value_min,
            value_max,
            extra_info: info,
        },
    ))
}

/// Returns a vector of graph arguments.
///
/// Use this function to build command line or CGI template for a `RRD::GRAPH` tag.
///
/// See <https://oss.oetiker.ch/rrdtool/doc/rrdcgi.en.html>.
///
/// # Errors
/// Returns an error if the graph arguments cannot be built or if the elements are invalid.
pub fn graph_args(
    image_format: Option<ImageFormat>,
    props: &GraphProps,
    elements: &[GraphElement],
) -> RrdResult<Vec<String>> {
    // detect error conditions that will confusingly produce no librrd output whatsoever
    if !elements.iter().any(|c| matches!(c, GraphElement::Def(_))) {
        return Err(RrdError::InvalidArgument(
            "Must have at least one Def element".to_string(),
        ));
    }
    if !elements.iter().any(|c| {
        matches!(
            c,
            GraphElement::Print(_)
                | GraphElement::GPrint(_)
                | GraphElement::Line(_)
                | GraphElement::Area(_)
        )
    }) {
        return Err(RrdError::InvalidArgument(
            "Must have at least one Line, Area, GPrint, or Print element".to_string(),
        ));
    }

    let mut args = Vec::new();
    if let Some(image_format) = image_format {
        image_format.append_to(&mut args)?;
    }
    props.append_to(&mut args)?;
    for c in elements {
        c.append_to(&mut args)?;
    }
    Ok(args)
}

/// Metadata about a rendered graph.
///
/// See [`graph`].
#[derive(Clone, Debug, PartialEq)]
pub struct GraphMetadata {
    /// Offset in pixels from the left edge of the image
    pub graph_left: u64,
    /// Offset in pixels from the top edge of the image
    pub graph_top: u64,
    /// Width in pixels of the graph in the image
    pub graph_width: u64,
    /// Height in pixels of the graph in the image
    pub graph_height: u64,
    /// Time at the start of the graph
    pub graph_start: Timestamp,
    /// Time at the end of the graph
    pub graph_end: Timestamp,
    /// Width in pixels
    pub image_width: u64,
    /// Height in pixels
    pub image_height: u64,
    /// Min value in the graph
    pub value_min: f64,
    /// Max value in the graph
    pub value_max: f64,
    /// Additional data returned from `rrd_graph_v`.
    ///
    /// Contents depend on the commands given.
    pub extra_info: collections::HashMap<String, InfoValue>,
}

/// RGB(A) color.
///
/// # Examples
///
/// `Color` can be parsed from a CSS-style 6 or 8 digit hex RGB(A) string.
///
/// RGB, no alpha:
///
/// ```
/// use rrd::ops::graph::Color;
/// let color: Color = "#012345".parse().unwrap();
/// assert_eq!(None, color.alpha);
/// ```
///
/// RGBA:
///
/// ```
/// use rrd::ops::graph::Color;
/// let color: Color = "#01234567".parse().unwrap();
/// assert_eq!(Some(0x67), color.alpha);
/// ```
///
/// See <https://oss.oetiker.ch/rrdtool/doc/rrdgraph.en.html>
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(missing_docs)]
pub struct Color {
    pub red: u8,
    pub green: u8,
    pub blue: u8,
    pub alpha: Option<u8>,
}

impl Color {
    /// Appends `#hex`.
    fn append_to(self, s: &mut String) {
        match self.alpha {
            None => write!(s, "#{:02X}{:02X}{:02X}", self.red, self.green, self.blue,),
            Some(alpha) => write!(
                s,
                "#{:02X}{:02X}{:02X}{:02X}",
                self.red, self.green, self.blue, alpha
            ),
        }
        .unwrap();
    }
}

impl std::str::FromStr for Color {
    type Err = InvalidArgument;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        combinator::map(
            combinator::all_consuming(sequence::preceded(
                bytes::complete::tag("#"),
                (
                    parse_hex_byte,
                    parse_hex_byte,
                    parse_hex_byte,
                    combinator::opt(parse_hex_byte),
                ),
            )),
            |(red, green, blue, alpha)| Color {
                red,
                green,
                blue,
                alpha,
            },
        )
        .parse_complete(s)
        .finish()
        .map_err(|_| InvalidArgument("Invalid color"))
        .map(|(_rem, c)| c)
    }
}

/// Incrementally build up the args to use in a graph invocation.
trait AppendArgs {
    /// Append suitable args to the args buffer.
    ///
    /// Returns Result to allow users to specify a `PathBuf` which may later fail conversion.
    fn append_to(&self, args: &mut Vec<String>) -> RrdResult<()>;
}

fn extract_info_value<T>(
    info: &mut collections::HashMap<String, InfoValue>,
    key: &str,
    transform: impl FnOnce(InfoValue) -> Option<T>,
) -> RrdResult<T> {
    let value = info
        .remove(key)
        .ok_or_else(|| RrdError::Internal(format!("Graph info: no {key}")))?;
    transform(value)
        .ok_or_else(|| RrdError::Internal(format!("Graph info: unexpected {key} value type")))
}

fn timestamp_from_count(count: u64) -> RrdResult<Timestamp> {
    let time_t = count
        .try_into()
        .map_err(|_| RrdError::Internal(format!("Graph timestamp {count} overflows time_t")))?;
    Timestamp::try_from_time_t(time_t)
}

fn parse_hex_byte(input: &str) -> nom::IResult<&str, u8> {
    combinator::map_opt(
        sequence::pair(complete::anychar, complete::anychar),
        |(hi, lo)| {
            let hi = u8::try_from(hi.to_digit(16)?).ok()?;
            let lo = u8::try_from(lo.to_digit(16)?).ok()?;

            Some((hi << 4) | lo)
        },
    )
    .parse_complete(input)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_color_no_alpha() {
        assert_eq!(
            Color {
                red: 0x01,
                green: 0x23,
                blue: 0x45,
                alpha: None,
            },
            "#012345".parse().unwrap()
        )
    }

    #[test]
    fn parse_color_with_alpha() {
        assert_eq!(
            Color {
                red: 0x01,
                green: 0x23,
                blue: 0x45,
                alpha: Some(0x67),
            },
            "#01234567".parse().unwrap()
        )
    }

    #[test]
    fn parse_color_err_invalid_hex() {
        assert!("#0000ZZ".parse::<Color>().is_err());
    }

    #[test]
    fn parse_color_err_no_prefix() {
        assert!("FFFFFF".parse::<Color>().is_err());
    }

    #[test]
    fn parse_color_err_wrong_length() {
        // too short
        assert!("#FFFFF".parse::<Color>().is_err());
        // in between rgb and rgba
        assert!("#FFFFFFF".parse::<Color>().is_err());
        // too long
        assert!("#FFFFFFFFF".parse::<Color>().is_err());
    }

    #[test]
    fn cgi_template() {
        let var_name: elements::VarName = "myspeed".try_into().unwrap();
        let rrd_path = std::path::PathBuf::from("data.rrd");
        let args = graph_args(
            Some(props::ImageFormat::Png),
            &props::GraphProps::default(),
            &[
                elements::Def {
                    var_name: var_name.clone(),
                    rrd: rrd_path,
                    ds_name: "speed".to_string(),
                    consolidation_fn: crate::ConsolidationFn::Avg,
                    step: None,
                    start: None,
                    end: None,
                    reduce: None,
                }
                .into(),
                elements::Line {
                    width: 2.0,
                    value: var_name,
                    color: Some(elements::ColorWithLegend {
                        color: Color {
                            red: 0xff,
                            green: 0x00,
                            blue: 0x00,
                            alpha: None,
                        },
                        legend: None,
                    }),
                    stack: false,
                    skip_scale: false,
                    dashes: None,
                }
                .into(),
            ],
        )
        .unwrap();
        let expected_args = vec![
            "--imgformat".to_string(),
            "PNG".to_string(),
            "DEF:myspeed=data.rrd:speed:AVERAGE".to_string(),
            "LINE2:myspeed#FF0000".to_string(),
        ];
        assert_eq!(args, expected_args);
    }
}