use crate::{error::Error, utils::common_path_ancestor};
use annotate_snippets::{Level, Renderer, Snippet};
use chrono::NaiveDateTime;
use clap::{Args, Subcommand, ValueEnum};
use serde::{Deserialize, Deserializer, Serialize};
use std::{
borrow::Cow,
fmt::Display,
fs,
path::{Path, PathBuf},
str::FromStr,
};
use strum::EnumIter;
use toml::de::Error as TomlError;
use tracing::{error, info};
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub struct GraphConfig {
pub panels: Vec<Panel>,
}
pub const DEFAULT_TIMESTAMP_STR: &str = "%Y-%m-%d %H:%M:%S%.3f";
pub const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat =
TimestampFormat::DateTime(Cow::Borrowed(DEFAULT_TIMESTAMP_STR));
#[derive(Clone, PartialEq, Debug, Serialize)]
pub enum TimestampFormat {
DateTime(Cow<'static, str>),
Time(Cow<'static, str>),
}
impl TimestampFormat {
pub fn as_str(&self) -> &str {
match self {
TimestampFormat::DateTime(cow) => cow.as_ref(),
TimestampFormat::Time(cow) => cow.as_ref(),
}
}
}
impl From<&str> for TimestampFormat {
fn from(s: &str) -> Self {
if Self::format_contains_date(s) {
TimestampFormat::DateTime(Cow::Owned(s.into()))
} else {
TimestampFormat::Time(Cow::Owned(s.into()))
}
}
}
impl<'de> Deserialize<'de> for TimestampFormat {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(Self::from(s.as_str()))
}
}
impl TimestampFormat {
fn format_contains_date(fmt: &str) -> bool {
const DATE_SPECIFIERS: [&str; 25] = [
"%Y", "%C", "%y", "%q", "%m", "%b", "%B", "%h", "%d", "%e", "%a", "%A", "%w", "%u",
"%U", "%W", "%G", "%g", "%V", "%j", "%D", "%x", "%F", "%v", "%s",
];
DATE_SPECIFIERS.iter().any(|&s| fmt.contains(s))
}
}
#[derive(Args, Debug, Serialize, Deserialize, Default)]
pub struct InputFilesContext {
#[arg(long, short = 'i', value_delimiter = ',', help_heading = "Input files")]
#[serde(skip)]
input: Vec<PathBuf>,
#[arg(long, value_name = "DIR", help_heading = "Output files")]
#[serde(skip)]
cache_dir: Option<PathBuf>,
#[arg(
long,
short = 'r',
default_value = None,
help_heading = "Input files",
)]
timestamp_format: Option<TimestampFormat>,
#[arg(long, short = 'f', default_value_t = false, help_heading = "Output files")]
#[serde(skip)]
force_csv_regen: bool,
#[arg(long, short = 't', default_value_t = false, help_heading = "Input files")]
#[serde(skip)]
ignore_invalid_timestamps: bool,
#[arg(long = "guard", help_heading = "Input files")]
#[serde(default)]
guards: Vec<String>,
}
#[derive(Args, Debug, Serialize, Deserialize, Default)]
pub struct GraphFullContext {
#[clap(flatten)]
#[serde(flatten)]
pub input_files_ctx: InputFilesContext,
#[clap(flatten)]
#[serde(flatten)]
pub output_graph_ctx: OutputGraphContext,
}
#[derive(Args, Debug, Serialize, Deserialize, Default)]
pub struct OutputGraphContext {
#[arg(long, num_args(0..=1), default_value = None, help_heading = "Panels layout", default_missing_value = "true")]
per_file_panels: Option<bool>,
#[arg(
long = "write-config",
short = 'w',
value_name = "CONFIG-FILE",
help_heading = "Output files"
)]
output_config_path: Option<PathBuf>,
#[arg(long, short = 'o', value_name = "FILE", help_heading = "Output files")]
output: Option<PathBuf>,
#[arg(
long,
value_name = "FILE",
value_parser = validate_standalone_filename,
help_heading = "Output files"
)]
inline_output: Option<PathBuf>,
#[arg(long, value_enum, conflicts_with = "time_range", help_heading = "Panels layout")]
panel_alignment_mode: Option<PanelAlignmentModeArg>,
#[arg(
long,
value_parser = TimeRangeArg::parse_time_range,
conflicts_with = "panel_alignment_mode",
help_heading = "Panels layout"
)]
#[serde(skip)]
time_range: Option<TimeRangeArg>,
#[arg(long, short = 'a', default_value_t = false, help_heading = "Output files")]
#[serde(skip)]
pub display_absolute_paths: bool,
#[arg(long, short = 'x', default_value_t = false, help_heading = "Output files")]
#[serde(skip)]
pub do_not_display: bool,
#[arg(long, short = 'p', default_value_t = false, help_heading = "Backend")]
#[serde(skip)]
pub plotly_backend: bool,
}
impl InputFilesContext {
pub fn new_with_input(input: Vec<PathBuf>) -> Self {
Self { input, ..Default::default() }
}
pub fn cache_dir(&self) -> &Option<PathBuf> {
&self.cache_dir
}
pub fn timestamp_format(&self) -> &TimestampFormat {
self.timestamp_format.as_ref().unwrap_or(&DEFAULT_TIMESTAMP_FORMAT)
}
pub fn input(&self) -> &Vec<PathBuf> {
&self.input
}
pub fn force_csv_regen(&self) -> bool {
self.force_csv_regen
}
pub fn ignore_invalid_timestamps(&self) -> bool {
self.ignore_invalid_timestamps
}
pub fn guards(&self) -> &Vec<String> {
&self.guards
}
}
pub enum OutputFilePaths {
Gnuplot((PathBuf, PathBuf)),
Plotly(PathBuf),
}
impl GraphFullContext {
pub fn merge_with_other(&mut self, other: Self) {
macro_rules! set_if_none {
($($field:tt)*) => {
if self.$($field)*.is_none() {
self.$($field)* = other.$($field)*;
}
};
}
set_if_none!(output_graph_ctx.per_file_panels);
set_if_none!(output_graph_ctx.inline_output);
set_if_none!(input_files_ctx.timestamp_format);
self.input_files_ctx.guards.extend(other.input_files_ctx.guards);
}
pub fn new_with_input(input: Vec<PathBuf>) -> Self {
Self {
input_files_ctx: InputFilesContext { input, ..Default::default() },
..Default::default()
}
}
pub fn timestamp_format(&self) -> &TimestampFormat {
self.input_files_ctx.timestamp_format()
}
pub fn input(&self) -> &Vec<PathBuf> {
&self.input_files_ctx.input
}
pub fn cache_dir(&self) -> &Option<PathBuf> {
&self.input_files_ctx.cache_dir
}
#[cfg(test)]
pub fn per_file_panels_option(&self) -> Option<bool> {
self.output_graph_ctx.per_file_panels
}
pub fn per_file_panels(&self) -> bool {
self.output_graph_ctx.per_file_panels.unwrap_or(false)
}
pub fn get_graph_output_path(&self) -> OutputFilePaths {
let common_ancestor =
common_path_ancestor(self.input()).unwrap_or_else(|| PathBuf::from("./"));
if self.output_graph_ctx.plotly_backend {
if let Some(ref output_file) = self.output_graph_ctx.inline_output {
let html_path = common_ancestor.join(output_file);
OutputFilePaths::Plotly(html_path.with_extension("html"))
} else {
let def = PathBuf::from("graph3.html");
let output_file = self.output_graph_ctx.output.as_ref().unwrap_or(&def);
let html_path = PathBuf::from(".").join(output_file);
OutputFilePaths::Plotly(html_path.with_extension("html"))
}
} else if let Some(ref output_file) = self.output_graph_ctx.inline_output {
let image_path = common_ancestor.join(output_file);
let gnuplot_path = image_path.with_extension("gnuplot");
OutputFilePaths::Gnuplot((image_path, gnuplot_path))
} else {
let def = PathBuf::from("graph.png");
let output_file = self.output_graph_ctx.output.as_ref().unwrap_or(&def);
let image_path = PathBuf::from(".").join(output_file);
let gnuplot_path = image_path.with_extension("gnuplot");
OutputFilePaths::Gnuplot((image_path, gnuplot_path))
}
}
pub fn output_config_path(&self) -> &Option<PathBuf> {
&self.output_graph_ctx.output_config_path
}
pub fn resolved_alignment_mode(
&self,
total_range: (NaiveDateTime, NaiveDateTime),
) -> Result<PanelAlignmentMode, crate::align_ranges::Error> {
if let Some(time_range) = &self.output_graph_ctx.time_range {
let resolved = time_range.resolve(total_range, self.timestamp_format())?;
return Ok(PanelAlignmentMode::Fixed(resolved.0, resolved.1));
}
Ok(match self.output_graph_ctx.panel_alignment_mode {
Some(PanelAlignmentModeArg::SharedOverlap) => PanelAlignmentMode::SharedOverlap,
Some(PanelAlignmentModeArg::SharedFull) | None => PanelAlignmentMode::SharedFull,
Some(PanelAlignmentModeArg::PerPanel) => PanelAlignmentMode::PerPanel,
})
}
}
impl OutputGraphContext {
#[cfg(test)]
pub fn per_file_panels_option(&self) -> Option<bool> {
self.per_file_panels
}
pub fn per_file_panels(&self) -> bool {
self.per_file_panels.unwrap_or(false)
}
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub struct Panel {
pub lines: Vec<Line>,
#[serde(flatten)]
pub params: PanelParams,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Line {
#[serde(flatten)]
pub data_source: DataSource,
#[serde(flatten)]
pub params: LineParams,
}
impl Line {
pub fn new_with_data_source(data_source: DataSource) -> Self {
Line { data_source, params: LineParams::default() }
}
}
#[derive(Default, Clone, Args, Debug, Serialize, Deserialize, PartialEq)]
pub struct LineParams {
#[arg(long)]
pub file_name: Option<PathBuf>,
#[arg(long)]
pub file_id: Option<usize>,
#[arg(long)]
pub title: Option<String>,
#[arg(long, default_value = "points")]
#[serde(default)]
pub style: PlotStyle,
#[arg(long)]
pub line_width: Option<LineWidth>,
#[arg(long)]
pub line_color: Option<Color>,
#[arg(long)]
pub dash_style: Option<DashStyle>,
#[arg(long)]
pub yaxis: Option<YAxis>,
#[arg(long)]
pub marker_type: Option<MarkerType>,
#[arg(long)]
pub marker_color: Option<Color>,
#[arg(long, default_value_t = MarkerSize::default())]
#[serde(default = "MarkerSize::default")]
pub marker_size: MarkerSize,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct LineWidth(pub f64);
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct MarkerSize(pub f64);
impl Display for LineWidth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f64::fmt(&self.0, f)
}
}
impl Display for MarkerSize {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f64::fmt(&self.0, f)
}
}
impl Default for LineWidth {
fn default() -> Self {
Self(1.0)
}
}
impl From<LineWidth> for f64 {
fn from(val: LineWidth) -> Self {
val.0
}
}
impl Default for MarkerSize {
fn default() -> Self {
Self(2.0)
}
}
impl FromStr for MarkerSize {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let l = s.parse::<f64>().map_err(|e| format!("MarkerSize parse error:{}", e))?;
if l <= 0.0 {
return Err(format!("MarkerSize: invalid value {l}"));
}
Ok(Self(l))
}
}
impl FromStr for LineWidth {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let l = s.parse::<f64>().map_err(|e| format!("LineWidth parse error:{}", e))?;
if l <= 0.0 {
return Err(format!("LineWidth: invalid value {l}"));
}
Ok(Self(l))
}
}
#[derive(Default, Clone, Args, Debug, Serialize, Deserialize, PartialEq)]
pub struct PanelParams {
#[arg(long)]
pub panel_title: Option<String>,
#[arg(long)]
pub height: Option<f64>,
#[arg(long)]
pub yaxis_scale: Option<AxisScale>,
#[arg(long)]
pub legend: Option<bool>,
#[arg(long)]
pub time_range_mode: Option<PanelRangeMode>,
}
#[derive(Debug, Clone, Copy, clap::ValueEnum, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum AxisScale {
Linear,
Log,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Args)]
pub struct FieldCaptureSpec {
pub guard: Option<String>,
pub field: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Args)]
pub struct EventDeltaSpec {
#[arg(required = false)]
pub guard: Option<String>,
pub pattern: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Subcommand)]
#[serde(tag = "data_source", rename_all = "snake_case")]
pub enum DataSource {
#[clap(name = "event")]
EventValue {
guard: Option<String>,
pattern: String,
yvalue: f64,
},
EventCount {
guard: Option<String>,
pattern: String,
},
EventDelta(EventDeltaSpec),
FieldValueSum(FieldCaptureSpec),
#[serde(untagged)]
#[clap(name = "plot")]
FieldValue(FieldCaptureSpec),
}
impl DataSource {
pub fn new_event_value(guard: Option<String>, pattern: String, yvalue: f64) -> Self {
DataSource::EventValue { guard, pattern, yvalue }
}
pub fn new_event_count(guard: Option<String>, pattern: String) -> Self {
DataSource::EventCount { guard, pattern }
}
pub fn new_event_delta(guard: Option<String>, pattern: String) -> Self {
DataSource::EventDelta(EventDeltaSpec { guard, pattern })
}
pub fn new_plot_field(guard: Option<String>, field: String) -> Self {
DataSource::FieldValue(FieldCaptureSpec { guard, field })
}
pub fn new_field_sum(guard: Option<String>, field: String) -> Self {
DataSource::FieldValueSum(FieldCaptureSpec { guard, field })
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub enum YAxis {
Y,
Y2,
}
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Deserialize, Serialize, EnumIter)]
#[serde(rename_all = "kebab-case")]
pub enum Color {
Red,
Blue,
DarkGreen,
Purple,
Cyan,
Goldenrod,
Brown,
Olive,
Navy,
Violet,
Coral,
Salmon,
SteelBlue,
DarkMagenta,
DarkCyan,
DarkYellow,
DarkTurquoise,
Yellow,
Black,
Magenta,
Orange,
Green,
DarkOrange,
}
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Deserialize, Serialize, EnumIter)]
#[serde(rename_all = "kebab-case")]
pub enum MarkerType {
Dot,
TriangleFilled,
SquareFilled,
DiamondFilled,
Plus,
Cross,
Circle,
X,
Triangle,
Square,
Diamond,
}
impl FromStr for MarkerType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
<MarkerType as ValueEnum>::from_str(s, true).map_err(|_| format!("Bad MarkerType: {}", s))
}
}
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Deserialize, Serialize, Default, EnumIter)]
#[serde(rename_all = "kebab-case")]
pub enum PlotStyle {
#[default]
Points,
Steps,
LinesPoints,
Lines,
}
impl FromStr for PlotStyle {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
<PlotStyle as ValueEnum>::from_str(s, true).map_err(|_| format!("Bad PlotStyle: {}", s))
}
}
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Deserialize, Serialize, Default, EnumIter)]
#[serde(rename_all = "kebab-case")]
pub enum DashStyle {
#[default]
Solid,
Dashed,
Dotted,
DashDot,
LongDash,
}
impl FromStr for DashStyle {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
<DashStyle as ValueEnum>::from_str(s, true).map_err(|_| format!("Bad DashStyle: {}", s))
}
}
impl FromStr for Color {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
<Color as ValueEnum>::from_str(s, true).map_err(|_| format!("Bad Color: {}", s))
}
}
impl From<&str> for Color {
fn from(s: &str) -> Self {
<Self as FromStr>::from_str(s).expect("Failed to convert &str to Color")
}
}
impl From<&str> for MarkerType {
fn from(s: &str) -> Self {
<Self as FromStr>::from_str(s).expect("Failed to convert &str to MarkerType")
}
}
fn validate_standalone_filename(s: &str) -> Result<PathBuf, String> {
let path = PathBuf::from(s);
if path.components().count() != 1 || !path.is_relative() {
Err(format!("Name '{s}' must be a filename only, without any directories"))
} else {
Ok(path)
}
}
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Deserialize, Serialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum PanelRangeMode {
#[default]
Full,
BestFit,
}
#[derive(Copy, Clone, Debug, PartialEq, Default)]
pub enum PanelAlignmentMode {
#[default]
SharedFull,
PerPanel,
SharedOverlap,
Fixed(NaiveDateTime, NaiveDateTime),
}
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
pub enum PanelAlignmentModeArg {
#[default]
SharedFull,
PerPanel,
SharedOverlap,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TimeRangeArg {
Relative(f64, f64),
AbsoluteDateTime(String, String),
}
impl TimeRangeArg {
pub fn parse_time_range(s: &str) -> Result<TimeRangeArg, String> {
let pieces: Vec<&str> = s.split(',').map(str::trim).collect();
if pieces.len() != 2 {
return Err("Expected two values separated by a comma".into());
}
if let (Ok(a), Ok(b)) = (pieces[0].parse::<f64>(), pieces[1].parse::<f64>()) {
if !(0.0..=1.0).contains(&a) || !(0.0..=1.0).contains(&b) || a >= b {
return Err("Relative range must be between 0.0 and 1.0, and start < end".into());
}
return Ok(TimeRangeArg::Relative(a, b));
}
Ok(TimeRangeArg::AbsoluteDateTime(pieces[0].into(), pieces[1].into()))
}
}
impl GraphConfig {
pub fn save_to_file(self: &GraphConfig, config_path: &Path) -> Result<(), Error> {
let toml_string = toml::to_string(self).expect("Failed to convert GraphConfig to TOML");
fs::write(config_path, toml_string)
.map(|_| info!("Config saved successfully: {:?}.", config_path))
.map_err(|e| Error::IoError(format!("{:?}", config_path), e))
}
pub fn load_from_file(path: &Path) -> Result<Self, Error> {
let content = fs::read_to_string(path).map_err(|error| {
error!(?error, "Reading toml error");
Error::IoError(format!("{}", path.display()), error)
})?;
toml::from_str(&content).map_err(|e| {
let r = annotate_toml_error(&e, &content, &path.display().to_string());
error!("{r}");
e.into()
})
}
}
pub fn annotate_toml_error(err: &TomlError, source: &str, filename: &str) -> String {
if let Some(span) = err.span() {
let snippet = Snippet::source(source)
.line_start(1)
.origin(filename)
.fold(true)
.annotation(Level::Error.span(span.clone()).label(err.message()));
let title = format!("Failed to parse {filename}");
let message = Level::Error.title(&title).snippet(snippet);
format!("{}", Renderer::styled().render(message))
} else {
err.to_string()
}
}