use std::fmt;
use std::sync::Arc;
pub type Result<T> = std::result::Result<T, PlottingError>;
pub type PlotResult<T> = std::result::Result<T, PlottingError>;
fn optional_clause<T: fmt::Display>(value: &Option<T>, prefix: &str, suffix: &str) -> String {
match value {
Some(value) => format!("{prefix}{value}{suffix}"),
None => String::new(),
}
}
#[derive(Clone, Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PlottingError {
#[error(
"Data length mismatch{}: x has {x_len} elements, y has {y_len} elements",
optional_clause(.series_index, " in series ", "")
)]
DataLengthMismatch {
x_len: usize,
y_len: usize,
series_index: Option<usize>,
},
#[error(
"{operation}{}: x, y, and z must have the same length (x={x_len}, y={y_len}, z={z_len})",
optional_clause(.series_index, " series ", "")
)]
DataLengthMismatch3D {
operation: &'static str,
x_len: usize,
y_len: usize,
z_len: usize,
series_index: Option<usize>,
},
#[error(
"{operation}: z shape must be (y.len(), x.len()) = ({expected_rows}, {expected_columns}), got ({actual_rows}, {actual_columns})"
)]
GridShapeMismatch {
operation: &'static str,
expected_rows: usize,
expected_columns: usize,
actual_rows: usize,
actual_columns: usize,
},
#[error("{context}: row {row} has {actual_columns} values, expected {expected_columns}")]
RaggedData2D {
context: &'static str,
row: usize,
expected_columns: usize,
actual_columns: usize,
},
#[error("camera: {field} {reason}, got {value}")]
InvalidCamera3D {
field: &'static str,
value: f32,
reason: &'static str,
},
#[error("Invalid 3D topology: {reason}")]
InvalidTopology3D { reason: String },
#[error("Empty data set provided - at least one data point is required")]
EmptyDataSet,
#[error("No data series added to plot - use line(), scatter(), or bar() to add data")]
NoDataSeries,
#[error("Invalid color specification: '{0}'")]
InvalidColor(String),
#[error("Invalid dimensions: {width}x{height} (minimum 100x100)")]
InvalidDimensions { width: u32, height: u32 },
#[error("Invalid DPI: {0} (minimum 72)")]
InvalidDPI(u32),
#[error("Invalid line width: {0} (must be positive)")]
InvalidLineWidth(f32),
#[error("Invalid alpha value: {0} (must be between 0.0 and 1.0)")]
InvalidAlpha(f32),
#[error("Invalid margin: {0} (must be between 0.0 and 0.5)")]
InvalidMargin(f32),
#[error("Font error: {0}")]
FontError(String),
#[error("Theme error: {0}")]
ThemeError(String),
#[error("interactive render superseded by a newer mutation, invalidation, or dirty update")]
RenderSuperseded,
#[error("Rendering error: {0}")]
RenderError(String),
#[error("Unsupported export format: '{0}'")]
UnsupportedFormat(String),
#[error("I/O error: {0}")]
IoError(#[source] Arc<std::io::Error>),
#[error("Out of memory during plotting operation")]
OutOfMemory,
#[error("Feature '{feature}' not enabled - required for operation: {operation}")]
FeatureNotEnabled { feature: String, operation: String },
#[error("{operation} is not supported: {reason}")]
UnsupportedOperation {
operation: &'static str,
reason: String,
},
#[error("System error: {0}")]
SystemError(String),
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Invalid annotation: {reason}")]
InvalidAnnotation { reason: String },
#[error("Unknown annotation ID for this interactive session")]
UnknownAnnotationId,
#[error("Invalid data{}: {message}", optional_clause(.position, " at position ", ""))]
InvalidData {
message: String,
position: Option<usize>,
},
#[error("Unsupported data type from {origin}: {dtype} (expected {expected})")]
DataTypeUnsupported {
origin: String,
dtype: String,
expected: String,
},
#[error(
"Null values are not allowed for {origin}{} ({null_count} null values found)",
optional_clause(.column, ".", "")
)]
NullValueNotAllowed {
origin: String,
column: Option<String>,
null_count: usize,
},
#[error("Failed to extract numeric data from {origin}: {message}")]
DataExtractionFailed {
origin: String,
message: String,
},
#[error("LaTeX rendering error: {0}")]
LatexError(String),
#[error("Typst rendering error: {0}")]
TypstError(String),
#[error("{limit_type} limit exceeded: {actual} (maximum {maximum})")]
PerformanceLimit {
limit_type: String,
actual: usize,
maximum: usize,
},
#[error("DataShader error: {message}{}", optional_clause(.cause, " (cause: ", ")"))]
DataShaderError {
message: String,
cause: Option<String>,
},
#[error("Aggregation '{operation}' failed on {data_points} points: {error}")]
AggregationError {
operation: String,
data_points: usize,
error: String,
},
#[error("DataShader canvas {width}x{height} exceeds maximum {max_pixels} pixels")]
DataShaderCanvasError {
width: u32,
height: u32,
max_pixels: usize,
},
#[error("Atomic operation error: {0}")]
AtomicOperationError(String),
#[error("Parallel rendering failed with {threads} threads: {error}")]
ParallelRenderError { threads: usize, error: String },
#[error("Thread pool error: {0}")]
ThreadPoolError(String),
#[error("Thread synchronization error: {0}")]
SynchronizationError(String),
#[error("Work stealing queue error: {0}")]
WorkStealingError(String),
#[error("GPU not available: {0}")]
GpuNotAvailable(String),
#[error("GPU initialization failed for {backend}: {error}")]
GpuInitError { backend: String, error: String },
#[error(
"GPU memory allocation failed: requested {requested} bytes{}",
optional_clause(.available, ", only ", " available")
)]
GpuMemoryError {
requested: usize,
available: Option<usize>,
},
#[error("Shader compilation failed for {shader_type}: {error}")]
ShaderError { shader_type: String, error: String },
#[error("GPU buffer error: {0}")]
BufferError(String),
#[error("GPU command error: {0}")]
CommandError(String),
#[error("GPU device lost - try restarting the application")]
DeviceLost,
#[error("GPU feature '{0}' not supported on this device")]
UnsupportedGpuFeature(String),
#[error("GPU operation timed out")]
GpuTimeoutError,
#[error("SIMD instructions not available on this CPU")]
SimdNotAvailable,
#[error("SIMD alignment error: required {required}-byte alignment, got {actual}")]
SimdAlignmentError { required: usize, actual: usize },
#[error("Memory pool initialization failed: {0}")]
PoolInitError(String),
#[error("{pool_type} pool exhausted: {requested} bytes requested")]
PoolExhausted { pool_type: String, requested: usize },
#[error("Memory pool corruption detected: {0}")]
PoolCorruption(String),
}
impl From<std::io::Error> for PlottingError {
fn from(err: std::io::Error) -> Self {
PlottingError::IoError(Arc::new(err))
}
}
impl From<crate::render::ColorError> for PlottingError {
fn from(err: crate::render::ColorError) -> Self {
PlottingError::InvalidColor(err.to_string())
}
}
#[cfg(feature = "gpu")]
impl From<crate::render::gpu::GpuError> for PlottingError {
fn from(err: crate::render::gpu::GpuError) -> Self {
use crate::render::gpu::GpuError;
match err {
GpuError::InitializationFailed(msg) => PlottingError::GpuInitError {
backend: "wgpu".to_string(),
error: msg,
},
GpuError::BufferCreationFailed(msg) => PlottingError::BufferError(msg),
GpuError::BufferOperationFailed(msg) => PlottingError::BufferError(msg),
GpuError::OperationFailed(msg) => PlottingError::CommandError(msg),
}
}
}
impl PlottingError {
pub fn is_render_superseded(&self) -> bool {
matches!(self, Self::RenderSuperseded)
}
pub fn validate_data(data: &[f64]) -> Result<()> {
for (i, &value) in data.iter().enumerate() {
if value.is_nan() {
return Err(PlottingError::InvalidData {
message: "NaN value found in data".to_string(),
position: Some(i),
});
}
if value.is_infinite() {
return Err(PlottingError::InvalidData {
message: format!("Infinite value ({}) found in data", value),
position: Some(i),
});
}
}
Ok(())
}
pub fn validate_dimensions(width: u32, height: u32) -> Result<()> {
const MIN_DIMENSION: u32 = 100;
if width < MIN_DIMENSION || height < MIN_DIMENSION {
return Err(PlottingError::InvalidDimensions { width, height });
}
Self::validate_subplot_dimensions(width, height)
}
pub(crate) fn validate_subplot_dimensions(width: u32, height: u32) -> Result<()> {
const MAX_DIMENSION: u32 = 16384;
if width == 0 || height == 0 {
return Err(PlottingError::InvalidDimensions { width, height });
}
if width > MAX_DIMENSION || height > MAX_DIMENSION {
return Err(PlottingError::PerformanceLimit {
limit_type: "Image dimension".to_string(),
actual: width.max(height) as usize,
maximum: MAX_DIMENSION as usize,
});
}
Ok(())
}
pub fn validate_dpi(dpi: u32) -> Result<()> {
const MIN_DPI: u32 = 72;
const MAX_DPI: u32 = 2400;
if dpi < MIN_DPI {
return Err(PlottingError::InvalidDPI(dpi));
}
if dpi > MAX_DPI {
return Err(PlottingError::PerformanceLimit {
limit_type: "DPI".to_string(),
actual: dpi as usize,
maximum: MAX_DPI as usize,
});
}
Ok(())
}
pub fn check_performance_limits(data_points: usize) -> Result<()> {
const SOFT_LIMIT: usize = 1_000_000; const HARD_LIMIT: usize = 100_000_000;
if data_points > HARD_LIMIT {
return Err(PlottingError::PerformanceLimit {
limit_type: "Data points".to_string(),
actual: data_points,
maximum: HARD_LIMIT,
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error;
#[test]
fn test_error_display() {
let err = PlottingError::DataLengthMismatch {
x_len: 5,
y_len: 3,
series_index: None,
};
assert!(err.to_string().contains("mismatch"));
assert!(err.to_string().contains("5"));
assert!(err.to_string().contains("3"));
}
#[test]
fn optional_clauses_read_the_same_with_and_without_their_field() {
assert_eq!(
PlottingError::DataLengthMismatch {
x_len: 5,
y_len: 3,
series_index: Some(2),
}
.to_string(),
"Data length mismatch in series 2: x has 5 elements, y has 3 elements"
);
assert_eq!(
PlottingError::DataLengthMismatch {
x_len: 5,
y_len: 3,
series_index: None,
}
.to_string(),
"Data length mismatch: x has 5 elements, y has 3 elements"
);
assert_eq!(
PlottingError::DataLengthMismatch3D {
operation: "scatter3",
x_len: 1,
y_len: 2,
z_len: 3,
series_index: Some(4),
}
.to_string(),
"scatter3 series 4: x, y, and z must have the same length (x=1, y=2, z=3)"
);
assert_eq!(
PlottingError::DataLengthMismatch3D {
operation: "scatter3",
x_len: 1,
y_len: 2,
z_len: 3,
series_index: None,
}
.to_string(),
"scatter3: x, y, and z must have the same length (x=1, y=2, z=3)"
);
assert_eq!(
PlottingError::InvalidData {
message: "NaN value found in data".to_string(),
position: Some(7),
}
.to_string(),
"Invalid data at position 7: NaN value found in data"
);
assert_eq!(
PlottingError::InvalidData {
message: "NaN value found in data".to_string(),
position: None,
}
.to_string(),
"Invalid data: NaN value found in data"
);
assert_eq!(
PlottingError::NullValueNotAllowed {
origin: "polars::DataFrame".to_string(),
column: Some("price".to_string()),
null_count: 3,
}
.to_string(),
"Null values are not allowed for polars::DataFrame.price (3 null values found)"
);
assert_eq!(
PlottingError::NullValueNotAllowed {
origin: "polars::Series".to_string(),
column: None,
null_count: 3,
}
.to_string(),
"Null values are not allowed for polars::Series (3 null values found)"
);
assert_eq!(
PlottingError::DataShaderError {
message: "canvas init".to_string(),
cause: Some("no device".to_string()),
}
.to_string(),
"DataShader error: canvas init (cause: no device)"
);
assert_eq!(
PlottingError::DataShaderError {
message: "canvas init".to_string(),
cause: None,
}
.to_string(),
"DataShader error: canvas init"
);
assert_eq!(
PlottingError::GpuMemoryError {
requested: 1024,
available: Some(512),
}
.to_string(),
"GPU memory allocation failed: requested 1024 bytes, only 512 available"
);
assert_eq!(
PlottingError::GpuMemoryError {
requested: 1024,
available: None,
}
.to_string(),
"GPU memory allocation failed: requested 1024 bytes"
);
}
#[test]
fn derived_messages_match_the_wording_callers_depend_on() {
assert_eq!(
PlottingError::GridShapeMismatch {
operation: "surface",
expected_rows: 2,
expected_columns: 3,
actual_rows: 2,
actual_columns: 2,
}
.to_string(),
"surface: z shape must be (y.len(), x.len()) = (2, 3), got (2, 2)"
);
assert_eq!(
PlottingError::RaggedData2D {
context: "surface",
row: 1,
expected_columns: 3,
actual_columns: 2,
}
.to_string(),
"surface: row 1 has 2 values, expected 3"
);
assert_eq!(
PlottingError::InvalidCamera3D {
field: "fov",
value: 0.0,
reason: "must be positive",
}
.to_string(),
"camera: fov must be positive, got 0"
);
assert_eq!(
PlottingError::DataExtractionFailed {
origin: "ruviz::plot-ingestion".to_string(),
message: "forced ingestion failure".to_string(),
}
.to_string(),
"Failed to extract numeric data from ruviz::plot-ingestion: forced ingestion failure"
);
assert_eq!(
PlottingError::DataTypeUnsupported {
origin: "polars::Series".to_string(),
dtype: "Utf8".to_string(),
expected: "numeric dtype".to_string(),
}
.to_string(),
"Unsupported data type from polars::Series: Utf8 (expected numeric dtype)"
);
assert_eq!(
PlottingError::InvalidDimensions {
width: 10,
height: 20,
}
.to_string(),
"Invalid dimensions: 10x20 (minimum 100x100)"
);
assert_eq!(
PlottingError::UnsupportedFormat("gif".to_string()).to_string(),
"Unsupported export format: 'gif'"
);
assert_eq!(
PlottingError::NoDataSeries.to_string(),
"No data series added to plot - use line(), scatter(), or bar() to add data"
);
assert_eq!(
PlottingError::EmptyDataSet.to_string(),
"Empty data set provided - at least one data point is required"
);
}
#[test]
fn test_data_validation() {
let valid_data = vec![1.0, 2.0, 3.0, 4.0];
assert!(PlottingError::validate_data(&valid_data).is_ok());
let nan_data = vec![1.0, f64::NAN, 3.0];
assert!(PlottingError::validate_data(&nan_data).is_err());
let inf_data = vec![1.0, f64::INFINITY, 3.0];
assert!(PlottingError::validate_data(&inf_data).is_err());
}
#[test]
fn test_dimension_validation() {
assert!(PlottingError::validate_dimensions(800, 600).is_ok());
assert!(PlottingError::validate_dimensions(50, 50).is_err());
assert!(PlottingError::validate_dimensions(20000, 20000).is_err());
assert!(PlottingError::validate_subplot_dimensions(1, 1).is_ok());
assert!(PlottingError::validate_subplot_dimensions(0, 1).is_err());
assert!(PlottingError::validate_subplot_dimensions(20000, 1).is_err());
}
#[test]
fn test_dpi_validation() {
assert!(PlottingError::validate_dpi(300).is_ok());
assert!(PlottingError::validate_dpi(50).is_err());
assert!(PlottingError::validate_dpi(5000).is_err());
}
#[test]
fn test_performance_limits() {
assert!(PlottingError::check_performance_limits(10000).is_ok());
assert!(PlottingError::check_performance_limits(1_000_000).is_ok());
assert!(PlottingError::check_performance_limits(200_000_000).is_err());
}
#[test]
fn test_error_source() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let plot_err = PlottingError::from(io_err);
assert!(plot_err.source().is_some());
assert_eq!(plot_err.to_string(), "I/O error: file not found");
}
#[test]
fn an_io_error_survives_being_cloned() {
let original = PlottingError::from(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"denied",
));
let copy = original.clone();
assert!(matches!(copy, PlottingError::IoError(_)));
assert_eq!(copy.to_string(), original.to_string());
assert_eq!(
copy.source().map(ToString::to_string),
Some("denied".to_string())
);
}
#[test]
fn a_data_origin_is_a_name_and_never_an_error_source() {
let err = PlottingError::DataExtractionFailed {
origin: "NumericData2D".to_string(),
message: "shape 2x3 does not match collected length 5".to_string(),
};
assert!(err.source().is_none());
}
#[test]
fn test_non_exhaustive_downstream_match_shape() {
let err = PlottingError::EmptyDataSet;
let described = match err {
PlottingError::EmptyDataSet => "empty",
PlottingError::NoDataSeries => "no series",
_ => "other",
};
assert_eq!(described, "empty");
}
#[test]
fn plot_result_alias_matches_result_alias() {
fn as_result(value: PlotResult<u32>) -> PlotResult<u32> {
value
}
assert_eq!(as_result(Ok(7)).unwrap(), 7);
assert!(as_result(Err(PlottingError::EmptyDataSet)).is_err());
let parsed: std::result::Result<u32, std::num::ParseIntError> = "42".parse();
assert_eq!(parsed.unwrap(), 42);
}
#[test]
fn test_color_error_conversion() {
let color_err = crate::render::ColorError::InvalidHex;
let plot_err = PlottingError::from(color_err);
match plot_err {
PlottingError::InvalidColor(_) => (),
_ => panic!("Expected InvalidColor"),
}
}
}