Skip to main content

trustformers_debug/
visualization_plugins.rs

1//! Custom Visualization Plugin System
2//!
3//! This module provides an extensible plugin system for custom visualizations,
4//! allowing users to create and register their own visualization tools.
5
6use anyhow::Result;
7use parking_lot::RwLock;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::PathBuf;
11use std::sync::Arc;
12
13/// Visualization data that can be passed to plugins
14#[derive(Debug, Clone)]
15pub enum VisualizationData {
16    /// 1D array data
17    Array1D(Vec<f64>),
18    /// 2D array data
19    Array2D(Vec<Vec<f64>>),
20    /// Tensor data with shape information
21    Tensor { data: Vec<f64>, shape: Vec<usize> },
22    /// Key-value pairs (for metadata, metrics, etc.)
23    KeyValue(HashMap<String, String>),
24    /// Time series data
25    TimeSeries {
26        timestamps: Vec<f64>,
27        values: Vec<f64>,
28        labels: Vec<String>,
29    },
30    /// Custom JSON data
31    Json(serde_json::Value),
32}
33
34/// Output format for visualizations
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36pub enum OutputFormat {
37    /// PNG image (not supported — requires binary encoder; use Svg instead)
38    Png,
39    /// SVG vector graphics
40    Svg,
41    /// HTML interactive visualization (SVG embedded in HTML)
42    Html,
43    /// Plain text/ASCII
44    Text,
45    /// JSON data
46    Json,
47    /// CSV data
48    Csv,
49}
50
51/// Plugin capabilities and metadata
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct PluginMetadata {
54    /// Plugin name
55    pub name: String,
56    /// Plugin version
57    pub version: String,
58    /// Plugin description
59    pub description: String,
60    /// Author
61    pub author: String,
62    /// Supported input data types
63    pub supported_inputs: Vec<String>,
64    /// Supported output formats
65    pub supported_outputs: Vec<OutputFormat>,
66    /// Tags/categories
67    pub tags: Vec<String>,
68}
69
70/// Configuration for visualization plugins
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct PluginConfig {
73    /// Output format
74    pub output_format: OutputFormat,
75    /// Output path (file or directory)
76    pub output_path: Option<String>,
77    /// Width in pixels (for image outputs)
78    pub width: usize,
79    /// Height in pixels (for image outputs)
80    pub height: usize,
81    /// Color scheme
82    pub color_scheme: String,
83    /// Additional custom parameters
84    pub custom_params: HashMap<String, serde_json::Value>,
85}
86
87impl Default for PluginConfig {
88    fn default() -> Self {
89        Self {
90            output_format: OutputFormat::Svg,
91            output_path: None,
92            width: 800,
93            height: 600,
94            color_scheme: "viridis".to_string(),
95            custom_params: HashMap::new(),
96        }
97    }
98}
99
100/// Result of plugin execution
101#[derive(Debug)]
102pub struct PluginResult {
103    /// Success status
104    pub success: bool,
105    /// Output file path (if file was created)
106    pub output_path: Option<String>,
107    /// Raw output data (if applicable)
108    pub output_data: Option<Vec<u8>>,
109    /// Metadata about the visualization
110    pub metadata: HashMap<String, String>,
111    /// Error message (if failed)
112    pub error: Option<String>,
113}
114
115/// Trait for visualization plugins
116///
117/// Implement this trait to create custom visualization plugins.
118pub trait VisualizationPlugin: Send + Sync {
119    /// Get plugin metadata
120    fn metadata(&self) -> PluginMetadata;
121
122    /// Execute the visualization
123    ///
124    /// # Arguments
125    /// * `data` - Input data to visualize
126    /// * `config` - Configuration for the visualization
127    ///
128    /// # Returns
129    /// Result containing the plugin output
130    fn execute(&self, data: VisualizationData, config: PluginConfig) -> Result<PluginResult>;
131
132    /// Validate input data
133    ///
134    /// # Arguments
135    /// * `data` - Data to validate
136    ///
137    /// # Returns
138    /// True if data is valid for this plugin
139    fn validate(&self, data: &VisualizationData) -> bool {
140        // Default implementation: accept all data
141        let _ = data;
142        true
143    }
144
145    /// Get configuration schema (for UI generation)
146    fn config_schema(&self) -> serde_json::Value {
147        serde_json::json!({
148            "type": "object",
149            "properties": {}
150        })
151    }
152}
153
154/// Plugin manager for registering and executing visualization plugins
155pub struct PluginManager {
156    /// Registered plugins
157    plugins: Arc<RwLock<HashMap<String, Box<dyn VisualizationPlugin>>>>,
158    /// Plugin execution history
159    history: Arc<RwLock<Vec<PluginExecution>>>,
160}
161
162/// Record of plugin execution
163#[derive(Debug, Clone)]
164struct PluginExecution {
165    plugin_name: String,
166    timestamp: std::time::SystemTime,
167    success: bool,
168    duration_ms: u128,
169}
170
171impl Default for PluginManager {
172    fn default() -> Self {
173        Self::new()
174    }
175}
176
177impl PluginManager {
178    /// Create a new plugin manager
179    pub fn new() -> Self {
180        let manager = Self {
181            plugins: Arc::new(RwLock::new(HashMap::new())),
182            history: Arc::new(RwLock::new(Vec::new())),
183        };
184
185        // Register built-in plugins
186        manager.register_builtin_plugins();
187
188        manager
189    }
190
191    /// Register built-in plugins
192    fn register_builtin_plugins(&self) {
193        // Register histogram plugin
194        self.register_plugin(Box::new(HistogramPlugin)).ok();
195
196        // Register heatmap plugin
197        self.register_plugin(Box::new(HeatmapPlugin)).ok();
198
199        // Register line plot plugin
200        self.register_plugin(Box::new(LinePlotPlugin)).ok();
201
202        // Register scatter plot plugin
203        self.register_plugin(Box::new(ScatterPlotPlugin)).ok();
204    }
205
206    /// Register a new plugin
207    ///
208    /// # Arguments
209    /// * `plugin` - Plugin to register
210    pub fn register_plugin(&self, plugin: Box<dyn VisualizationPlugin>) -> Result<()> {
211        let name = plugin.metadata().name.clone();
212
213        self.plugins.write().insert(name.clone(), plugin);
214
215        tracing::info!(plugin_name = %name, "Registered visualization plugin");
216
217        Ok(())
218    }
219
220    /// Unregister a plugin
221    ///
222    /// # Arguments
223    /// * `name` - Name of plugin to unregister
224    pub fn unregister_plugin(&self, name: &str) -> Result<()> {
225        self.plugins.write().remove(name);
226
227        tracing::info!(plugin_name = %name, "Unregistered visualization plugin");
228
229        Ok(())
230    }
231
232    /// Get list of registered plugins
233    pub fn list_plugins(&self) -> Vec<PluginMetadata> {
234        self.plugins.read().values().map(|p| p.metadata()).collect()
235    }
236
237    /// Execute a plugin
238    ///
239    /// # Arguments
240    /// * `plugin_name` - Name of plugin to execute
241    /// * `data` - Input data
242    /// * `config` - Configuration
243    pub fn execute(
244        &self,
245        plugin_name: &str,
246        data: VisualizationData,
247        config: PluginConfig,
248    ) -> Result<PluginResult> {
249        let start_time = std::time::Instant::now();
250
251        let result = {
252            let plugins = self.plugins.read();
253            let plugin = plugins
254                .get(plugin_name)
255                .ok_or_else(|| anyhow::anyhow!("Plugin not found: {}", plugin_name))?;
256
257            // Validate data
258            if !plugin.validate(&data) {
259                anyhow::bail!("Invalid data for plugin: {}", plugin_name);
260            }
261
262            plugin.execute(data, config)?
263        };
264
265        let duration = start_time.elapsed().as_millis();
266
267        // Record execution
268        self.history.write().push(PluginExecution {
269            plugin_name: plugin_name.to_string(),
270            timestamp: std::time::SystemTime::now(),
271            success: result.success,
272            duration_ms: duration,
273        });
274
275        Ok(result)
276    }
277
278    /// Get plugin by name
279    pub fn get_plugin(&self, name: &str) -> Option<PluginMetadata> {
280        self.plugins.read().get(name).map(|p| p.metadata())
281    }
282
283    /// Get execution history
284    pub fn get_history(&self) -> Vec<String> {
285        self.history
286            .read()
287            .iter()
288            .map(|e| {
289                format!(
290                    "{}: {} ({}ms) - {}",
291                    e.timestamp.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(),
292                    e.plugin_name,
293                    e.duration_ms,
294                    if e.success { "success" } else { "failed" }
295                )
296            })
297            .collect()
298    }
299}
300
301// ============================================================================
302// SVG rendering — pure handwritten, no external drawing dep
303// ============================================================================
304
305mod svg_render {
306    //! Pure-Rust handwritten SVG generators.
307    //!
308    //! Each function returns a complete, self-contained SVG document.
309    //! Layout constants and a basic coordinate mapper are shared across helpers.
310
311    use super::PluginConfig;
312
313    // Layout constants (pixels inside the SVG viewBox)
314    const MARGIN_TOP: f64 = 40.0;
315    const MARGIN_BOTTOM: f64 = 50.0;
316    const MARGIN_LEFT: f64 = 60.0;
317    const MARGIN_RIGHT: f64 = 20.0;
318    const AXIS_TICK_LEN: f64 = 5.0;
319
320    // SVG attribute strings (literal, not format arguments)
321    const FONT_ATTR: &str = r#"font-family="sans-serif""#;
322    const AXIS_COLOR: &str = "#555";
323    const BAR_COLOR: &str = "#4878CF";
324
325    /// Clamp a value to [lo, hi]
326    fn clamp_f64(v: f64, lo: f64, hi: f64) -> f64 {
327        if v < lo {
328            lo
329        } else if v > hi {
330            hi
331        } else {
332            v
333        }
334    }
335
336    /// Map a data value to a pixel coordinate within the plot area.
337    fn map_x(v: f64, data_min: f64, data_max: f64, px_left: f64, px_right: f64) -> f64 {
338        let range = data_max - data_min;
339        if range == 0.0 {
340            return (px_left + px_right) / 2.0;
341        }
342        let t = (v - data_min) / range;
343        clamp_f64(px_left + t * (px_right - px_left), px_left, px_right)
344    }
345
346    /// Map a data value to a pixel y-coordinate (data grows up, SVG y grows down).
347    fn map_y(v: f64, data_min: f64, data_max: f64, px_top: f64, px_bottom: f64) -> f64 {
348        let range = data_max - data_min;
349        if range == 0.0 {
350            return (px_top + px_bottom) / 2.0;
351        }
352        let t = (v - data_min) / range;
353        clamp_f64(px_bottom - t * (px_bottom - px_top), px_top, px_bottom)
354    }
355
356    /// SVG header with explicit width/height and a white background.
357    fn svg_open(width: usize, height: usize) -> String {
358        format!(
359            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\">\n\
360             <rect width=\"{w}\" height=\"{h}\" fill=\"white\"/>\n",
361            w = width,
362            h = height,
363        )
364    }
365
366    fn svg_close() -> &'static str {
367        "</svg>"
368    }
369
370    /// Render a title centred at the top of the SVG.
371    fn svg_title(text: &str, width: usize) -> String {
372        let cx = width / 2;
373        let escaped = escape_xml(text);
374        format!(
375            "<text x=\"{cx}\" y=\"24\" text-anchor=\"middle\" {font} font-size=\"16\" fill=\"#333\">{text}</text>\n",
376            cx = cx,
377            font = FONT_ATTR,
378            text = escaped,
379        )
380    }
381
382    /// Render axis lines, tick marks and numeric tick labels.
383    fn svg_axes(
384        px_left: f64,
385        px_right: f64,
386        px_top: f64,
387        px_bottom: f64,
388        x_min: f64,
389        x_max: f64,
390        y_min: f64,
391        y_max: f64,
392        x_label: &str,
393        y_label: &str,
394        _width: usize,
395        height: usize,
396        n_ticks: usize,
397    ) -> String {
398        let mut out = String::new();
399        let c = AXIS_COLOR;
400
401        // Vertical axis line (left)
402        out.push_str(&format!(
403            "<line x1=\"{x1:.2}\" y1=\"{y1:.2}\" x2=\"{x2:.2}\" y2=\"{y2:.2}\" stroke=\"{c}\" stroke-width=\"1\"/>\n",
404            x1 = px_left, y1 = px_top, x2 = px_left, y2 = px_bottom, c = c,
405        ));
406        // Horizontal axis line (bottom)
407        out.push_str(&format!(
408            "<line x1=\"{x1:.2}\" y1=\"{y1:.2}\" x2=\"{x2:.2}\" y2=\"{y2:.2}\" stroke=\"{c}\" stroke-width=\"1\"/>\n",
409            x1 = px_left, y1 = px_bottom, x2 = px_right, y2 = px_bottom, c = c,
410        ));
411
412        // X ticks
413        let x_range = x_max - x_min;
414        for i in 0..=n_ticks {
415            let frac = i as f64 / n_ticks as f64;
416            let val = x_min + frac * x_range;
417            let px = px_left + frac * (px_right - px_left);
418            let ty = px_bottom + AXIS_TICK_LEN + 12.0;
419            let y2 = px_bottom + AXIS_TICK_LEN;
420            out.push_str(&format!(
421                "<line x1=\"{px:.2}\" y1=\"{y1:.2}\" x2=\"{px:.2}\" y2=\"{y2:.2}\" stroke=\"{c}\" stroke-width=\"1\"/>\n\
422                 <text x=\"{px:.2}\" y=\"{ty:.2}\" text-anchor=\"middle\" {font} font-size=\"10\" fill=\"{c}\">{val:.2}</text>\n",
423                px = px, y1 = px_bottom, y2 = y2, ty = ty, c = c, font = FONT_ATTR, val = val,
424            ));
425        }
426
427        // Y ticks
428        let y_range = y_max - y_min;
429        for i in 0..=n_ticks {
430            let frac = i as f64 / n_ticks as f64;
431            let val = y_min + frac * y_range;
432            let py = px_bottom - frac * (px_bottom - px_top);
433            let tick_x1 = px_left - AXIS_TICK_LEN;
434            let tx = tick_x1 - 2.0;
435            let py_t = py + 4.0;
436            out.push_str(&format!(
437                "<line x1=\"{tx1:.2}\" y1=\"{py:.2}\" x2=\"{x2:.2}\" y2=\"{py:.2}\" stroke=\"{c}\" stroke-width=\"1\"/>\n\
438                 <text x=\"{tx:.2}\" y=\"{pyt:.2}\" text-anchor=\"end\" {font} font-size=\"10\" fill=\"{c}\">{val:.2}</text>\n",
439                tx1 = tick_x1, py = py, x2 = px_left, tx = tx, pyt = py_t,
440                c = c, font = FONT_ATTR, val = val,
441            ));
442        }
443
444        // Axis labels
445        if !x_label.is_empty() {
446            let lx = (px_left + px_right) / 2.0;
447            let ly = height as f64 - 4.0;
448            let label = escape_xml(x_label);
449            out.push_str(&format!(
450                "<text x=\"{lx:.2}\" y=\"{ly:.2}\" text-anchor=\"middle\" {font} font-size=\"12\" fill=\"{c}\">{label}</text>\n",
451                lx = lx, ly = ly, font = FONT_ATTR, c = c, label = label,
452            ));
453        }
454        if !y_label.is_empty() {
455            let ry_x = -((px_top + px_bottom) / 2.0);
456            let label = escape_xml(y_label);
457            out.push_str(&format!(
458                "<text transform=\"rotate(-90)\" x=\"{rx:.2}\" y=\"14\" text-anchor=\"middle\" {font} font-size=\"12\" fill=\"{c}\">{label}</text>\n",
459                rx = ry_x, font = FONT_ATTR, c = c, label = label,
460            ));
461        }
462
463        out
464    }
465
466    /// Escape XML special characters in text content.
467    fn escape_xml(s: &str) -> String {
468        s.replace('&', "&amp;")
469            .replace('<', "&lt;")
470            .replace('>', "&gt;")
471            .replace('"', "&quot;")
472    }
473
474    // -------------------------------------------------------------------------
475    // Public renderers
476    // -------------------------------------------------------------------------
477
478    /// Render a histogram. `bins` is a slice of `(bin_left, bin_right, count)`.
479    pub fn histogram(bins: &[(f64, f64, usize)], config: &PluginConfig) -> String {
480        let w = config.width;
481        let h = config.height;
482        let px_left = MARGIN_LEFT;
483        let px_right = w as f64 - MARGIN_RIGHT;
484        let px_top = MARGIN_TOP;
485        let px_bottom = h as f64 - MARGIN_BOTTOM;
486
487        let title = config
488            .custom_params
489            .get("title")
490            .and_then(|v| v.as_str())
491            .unwrap_or("Histogram");
492        let x_label =
493            config.custom_params.get("x_label").and_then(|v| v.as_str()).unwrap_or("Value");
494        let y_label =
495            config.custom_params.get("y_label").and_then(|v| v.as_str()).unwrap_or("Count");
496
497        let max_count = bins.iter().map(|b| b.2).max().unwrap_or(1).max(1);
498        let x_min = bins.first().map(|b| b.0).unwrap_or(0.0);
499        let x_max = bins.last().map(|b| b.1).unwrap_or(1.0);
500
501        let mut out = svg_open(w, h);
502        out.push_str(&svg_title(title, w));
503        out.push_str(&svg_axes(
504            px_left,
505            px_right,
506            px_top,
507            px_bottom,
508            x_min,
509            x_max,
510            0.0,
511            max_count as f64,
512            x_label,
513            y_label,
514            w,
515            h,
516            5,
517        ));
518
519        // Draw bars
520        let fill = BAR_COLOR;
521        for (bin_left, bin_right, count) in bins {
522            let bx1 = map_x(*bin_left, x_min, x_max, px_left, px_right);
523            let bx2 = map_x(*bin_right, x_min, x_max, px_left, px_right);
524            let by_top = map_y(*count as f64, 0.0, max_count as f64, px_top, px_bottom);
525            let bar_h = px_bottom - by_top;
526            let bar_w = (bx2 - bx1).max(1.0);
527            out.push_str(&format!(
528                "<rect x=\"{x:.2}\" y=\"{y:.2}\" width=\"{bw:.2}\" height=\"{bh:.2}\" fill=\"{fill}\" stroke=\"white\" stroke-width=\"1\"/>\n",
529                x = bx1, y = by_top, bw = bar_w, bh = bar_h, fill = fill,
530            ));
531        }
532
533        out.push_str(svg_close());
534        out
535    }
536
537    /// Render a heatmap. `values` is row-major with dimensions `rows × cols`.
538    pub fn heatmap(rows: usize, cols: usize, values: &[f64], config: &PluginConfig) -> String {
539        let w = config.width;
540        let h = config.height;
541        let px_left = MARGIN_LEFT;
542        let px_right = w as f64 - MARGIN_RIGHT;
543        let px_top = MARGIN_TOP;
544        let px_bottom = h as f64 - MARGIN_BOTTOM;
545
546        let title = config.custom_params.get("title").and_then(|v| v.as_str()).unwrap_or("Heatmap");
547
548        let cell_w = if cols > 0 { (px_right - px_left) / cols as f64 } else { 1.0 };
549        let cell_h = if rows > 0 { (px_bottom - px_top) / rows as f64 } else { 1.0 };
550
551        let (v_min, v_max) =
552            values.iter().copied().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| {
553                (lo.min(v), hi.max(v))
554            });
555        let v_range = (v_max - v_min).max(f64::EPSILON);
556
557        let mut out = svg_open(w, h);
558        out.push_str(&svg_title(title, w));
559
560        // Draw cells
561        for row_idx in 0..rows {
562            for col_idx in 0..cols {
563                let idx = row_idx * cols + col_idx;
564                let val = values.get(idx).copied().unwrap_or(0.0);
565                let t = ((val - v_min) / v_range).clamp(0.0, 1.0);
566                // Viridis-like gradient: dark purple to yellow
567                let red = (255.0 * (t * t)) as u8;
568                let green = (255.0 * t * (1.0 - t * 0.5)) as u8;
569                let blue = (255.0 * (1.0 - t)) as u8;
570                let cx = px_left + col_idx as f64 * cell_w;
571                let cy = px_top + row_idx as f64 * cell_h;
572                out.push_str(&format!(
573                    "<rect x=\"{x:.2}\" y=\"{y:.2}\" width=\"{cw:.2}\" height=\"{ch:.2}\" fill=\"rgb({r},{g},{b})\"/>\n",
574                    x = cx, y = cy, cw = cell_w, ch = cell_h,
575                    r = red, g = green, b = blue,
576                ));
577            }
578        }
579
580        out.push_str(svg_close());
581        out
582    }
583
584    /// Render a line plot. `points` is a slice of `(x, y)` pairs, ordered by x.
585    pub fn line_plot(points: &[(f64, f64)], config: &PluginConfig) -> String {
586        let w = config.width;
587        let h = config.height;
588        let px_left = MARGIN_LEFT;
589        let px_right = w as f64 - MARGIN_RIGHT;
590        let px_top = MARGIN_TOP;
591        let px_bottom = h as f64 - MARGIN_BOTTOM;
592
593        let title = config
594            .custom_params
595            .get("title")
596            .and_then(|v| v.as_str())
597            .unwrap_or("Line Plot");
598        let x_label = config.custom_params.get("x_label").and_then(|v| v.as_str()).unwrap_or("X");
599        let y_label = config.custom_params.get("y_label").and_then(|v| v.as_str()).unwrap_or("Y");
600
601        let (x_min, x_max, y_min, y_max) = data_bounds(points);
602
603        let mut out = svg_open(w, h);
604        out.push_str(&svg_title(title, w));
605        out.push_str(&svg_axes(
606            px_left, px_right, px_top, px_bottom, x_min, x_max, y_min, y_max, x_label, y_label, w,
607            h, 5,
608        ));
609
610        if !points.is_empty() {
611            // Build a polyline
612            let pts: Vec<String> = points
613                .iter()
614                .map(|(x, y)| {
615                    let px = map_x(*x, x_min, x_max, px_left, px_right);
616                    let py = map_y(*y, y_min, y_max, px_top, px_bottom);
617                    format!("{:.2},{:.2}", px, py)
618                })
619                .collect();
620            let stroke = BAR_COLOR;
621            out.push_str(&format!(
622                "<polyline points=\"{pts}\" fill=\"none\" stroke=\"{stroke}\" stroke-width=\"2\"/>\n",
623                pts = pts.join(" "),
624                stroke = stroke,
625            ));
626        }
627
628        out.push_str(svg_close());
629        out
630    }
631
632    /// Render a scatter plot. `points` is a slice of `(x, y)` pairs.
633    pub fn scatter(points: &[(f64, f64)], config: &PluginConfig) -> String {
634        let w = config.width;
635        let h = config.height;
636        let px_left = MARGIN_LEFT;
637        let px_right = w as f64 - MARGIN_RIGHT;
638        let px_top = MARGIN_TOP;
639        let px_bottom = h as f64 - MARGIN_BOTTOM;
640
641        let title = config
642            .custom_params
643            .get("title")
644            .and_then(|v| v.as_str())
645            .unwrap_or("Scatter Plot");
646        let x_label = config.custom_params.get("x_label").and_then(|v| v.as_str()).unwrap_or("X");
647        let y_label = config.custom_params.get("y_label").and_then(|v| v.as_str()).unwrap_or("Y");
648
649        let (x_min, x_max, y_min, y_max) = data_bounds(points);
650
651        let mut out = svg_open(w, h);
652        out.push_str(&svg_title(title, w));
653        out.push_str(&svg_axes(
654            px_left, px_right, px_top, px_bottom, x_min, x_max, y_min, y_max, x_label, y_label, w,
655            h, 5,
656        ));
657
658        let fill = BAR_COLOR;
659        for (x, y) in points {
660            let px = map_x(*x, x_min, x_max, px_left, px_right);
661            let py = map_y(*y, y_min, y_max, px_top, px_bottom);
662            out.push_str(&format!(
663                "<circle cx=\"{cx:.2}\" cy=\"{cy:.2}\" r=\"4\" fill=\"{fill}\" fill-opacity=\"0.7\"/>\n",
664                cx = px, cy = py, fill = fill,
665            ));
666        }
667
668        out.push_str(svg_close());
669        out
670    }
671
672    // -------------------------------------------------------------------------
673    // JSON / CSV renderers
674    // -------------------------------------------------------------------------
675
676    /// Render histogram data as JSON.
677    pub fn histogram_json(
678        bins: &[(f64, f64, usize)],
679        min: f64,
680        max: f64,
681        n_values: usize,
682    ) -> String {
683        let bins_arr: Vec<serde_json::Value> = bins
684            .iter()
685            .map(|(lo, hi, cnt)| {
686                serde_json::json!({
687                    "bin_left": lo,
688                    "bin_right": hi,
689                    "count": cnt,
690                })
691            })
692            .collect();
693        serde_json::to_string_pretty(&serde_json::json!({
694            "type": "histogram",
695            "n_values": n_values,
696            "min": min,
697            "max": max,
698            "bins": bins_arr,
699        }))
700        .unwrap_or_else(|_| "{}".to_string())
701    }
702
703    /// Render histogram data as CSV.
704    pub fn histogram_csv(bins: &[(f64, f64, usize)]) -> String {
705        let mut out = String::from("bin_left,bin_right,count\n");
706        for (lo, hi, cnt) in bins {
707            out.push_str(&format!("{},{},{}\n", lo, hi, cnt));
708        }
709        out
710    }
711
712    /// Render 2D point data as JSON.
713    pub fn points_json(kind: &str, points: &[(f64, f64)]) -> String {
714        let pts: Vec<serde_json::Value> =
715            points.iter().map(|(x, y)| serde_json::json!({ "x": x, "y": y })).collect();
716        serde_json::to_string_pretty(&serde_json::json!({
717            "type": kind,
718            "n_points": points.len(),
719            "points": pts,
720        }))
721        .unwrap_or_else(|_| "{}".to_string())
722    }
723
724    /// Render 2D point data as CSV.
725    pub fn points_csv(points: &[(f64, f64)]) -> String {
726        let mut out = String::from("x,y\n");
727        for (x, y) in points {
728            out.push_str(&format!("{},{}\n", x, y));
729        }
730        out
731    }
732
733    /// Wrap an SVG string in a minimal HTML document.
734    pub fn wrap_html(svg: &str) -> String {
735        format!(
736            "<!DOCTYPE html><html><head><meta charset=\"utf-8\"/></head><body>{svg}</body></html>",
737            svg = svg
738        )
739    }
740
741    // -------------------------------------------------------------------------
742    // Internal helpers
743    // -------------------------------------------------------------------------
744
745    /// Compute (x_min, x_max, y_min, y_max) for a slice of (x, y) points.
746    fn data_bounds(points: &[(f64, f64)]) -> (f64, f64, f64, f64) {
747        let mut x_min = f64::INFINITY;
748        let mut x_max = f64::NEG_INFINITY;
749        let mut y_min = f64::INFINITY;
750        let mut y_max = f64::NEG_INFINITY;
751        for (x, y) in points {
752            if *x < x_min {
753                x_min = *x;
754            }
755            if *x > x_max {
756                x_max = *x;
757            }
758            if *y < y_min {
759                y_min = *y;
760            }
761            if *y > y_max {
762                y_max = *y;
763            }
764        }
765        // Pad a little so points at edge are visible
766        let x_pad = if (x_max - x_min).abs() < f64::EPSILON { 1.0 } else { (x_max - x_min) * 0.05 };
767        let y_pad = if (y_max - y_min).abs() < f64::EPSILON { 1.0 } else { (y_max - y_min) * 0.05 };
768        (x_min - x_pad, x_max + x_pad, y_min - y_pad, y_max + y_pad)
769    }
770}
771
772// ============================================================================
773// Helper: write bytes to output_path when configured
774// ============================================================================
775
776fn write_output_path(path: &str, bytes: &[u8]) -> Result<()> {
777    let p = PathBuf::from(path);
778    std::fs::write(&p, bytes)
779        .map_err(|e| anyhow::anyhow!("Failed to write output to {}: {}", p.display(), e))
780}
781
782// ============================================================================
783// Built-in Plugins
784// ============================================================================
785
786/// Histogram visualization plugin
787struct HistogramPlugin;
788
789impl VisualizationPlugin for HistogramPlugin {
790    fn metadata(&self) -> PluginMetadata {
791        PluginMetadata {
792            name: "histogram".to_string(),
793            version: "1.0.0".to_string(),
794            description: "Generates histogram visualizations".to_string(),
795            author: "TrustformeRS".to_string(),
796            supported_inputs: vec!["Array1D".to_string(), "Tensor".to_string()],
797            supported_outputs: vec![
798                OutputFormat::Svg,
799                OutputFormat::Html,
800                OutputFormat::Text,
801                OutputFormat::Json,
802                OutputFormat::Csv,
803            ],
804            tags: vec!["distribution".to_string(), "statistics".to_string()],
805        }
806    }
807
808    fn config_schema(&self) -> serde_json::Value {
809        serde_json::json!({
810            "width": 800,
811            "height": 600,
812            "title": "Histogram",
813            "x_label": "Value",
814            "y_label": "Count",
815            "bins": 20
816        })
817    }
818
819    fn execute(&self, data: VisualizationData, config: PluginConfig) -> Result<PluginResult> {
820        let values = match data {
821            VisualizationData::Array1D(v) => v,
822            VisualizationData::Tensor { data, .. } => data,
823            _ => anyhow::bail!("Unsupported data type for histogram"),
824        };
825
826        if values.is_empty() {
827            anyhow::bail!("Histogram requires non-empty input data");
828        }
829
830        // Calculate histogram bins
831        let n_bins =
832            config.custom_params.get("bins").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
833        let n_bins = n_bins.max(1);
834
835        let min = values.iter().copied().fold(f64::INFINITY, f64::min);
836        let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
837        let bin_width =
838            if (max - min).abs() < f64::EPSILON { 1.0 } else { (max - min) / n_bins as f64 };
839
840        let mut counts = vec![0usize; n_bins];
841        for &value in &values {
842            let bin_idx = ((value - min) / bin_width).floor() as usize;
843            let bin_idx = bin_idx.min(n_bins - 1);
844            counts[bin_idx] += 1;
845        }
846
847        let bins: Vec<(f64, f64, usize)> = counts
848            .iter()
849            .enumerate()
850            .map(|(i, &cnt)| {
851                let lo = min + i as f64 * bin_width;
852                let hi = lo + bin_width;
853                (lo, hi, cnt)
854            })
855            .collect();
856
857        let bytes = match config.output_format {
858            OutputFormat::Png => {
859                anyhow::bail!(
860                    "PNG output is not supported (requires binary encoder); use Svg instead"
861                )
862            },
863            OutputFormat::Svg => {
864                let svg = svg_render::histogram(&bins, &config);
865                svg.into_bytes()
866            },
867            OutputFormat::Html => {
868                let svg = svg_render::histogram(&bins, &config);
869                svg_render::wrap_html(&svg).into_bytes()
870            },
871            OutputFormat::Text => {
872                let output_text = format!(
873                    "Histogram (bins={}):\nMin={:.4}, Max={:.4}\nBin counts: {:?}",
874                    n_bins, min, max, counts
875                );
876                output_text.into_bytes()
877            },
878            OutputFormat::Json => {
879                svg_render::histogram_json(&bins, min, max, values.len()).into_bytes()
880            },
881            OutputFormat::Csv => svg_render::histogram_csv(&bins).into_bytes(),
882        };
883
884        let out_path_str = if let Some(ref path) = config.output_path {
885            write_output_path(path, &bytes)?;
886            Some(path.clone())
887        } else {
888            None
889        };
890
891        Ok(PluginResult {
892            success: true,
893            output_path: out_path_str,
894            output_data: Some(bytes),
895            metadata: {
896                let mut m = HashMap::new();
897                m.insert("bins".to_string(), n_bins.to_string());
898                m.insert("min".to_string(), min.to_string());
899                m.insert("max".to_string(), max.to_string());
900                m.insert("n_values".to_string(), values.len().to_string());
901                m
902            },
903            error: None,
904        })
905    }
906
907    fn validate(&self, data: &VisualizationData) -> bool {
908        matches!(
909            data,
910            VisualizationData::Array1D(_) | VisualizationData::Tensor { .. }
911        )
912    }
913}
914
915/// Heatmap visualization plugin
916struct HeatmapPlugin;
917
918impl VisualizationPlugin for HeatmapPlugin {
919    fn metadata(&self) -> PluginMetadata {
920        PluginMetadata {
921            name: "heatmap".to_string(),
922            version: "1.0.0".to_string(),
923            description: "Generates heatmap visualizations for 2D data".to_string(),
924            author: "TrustformeRS".to_string(),
925            supported_inputs: vec!["Array2D".to_string(), "Tensor".to_string()],
926            supported_outputs: vec![
927                OutputFormat::Svg,
928                OutputFormat::Html,
929                OutputFormat::Json,
930                OutputFormat::Csv,
931                OutputFormat::Text,
932            ],
933            tags: vec!["matrix".to_string(), "2d".to_string()],
934        }
935    }
936
937    fn config_schema(&self) -> serde_json::Value {
938        serde_json::json!({
939            "width": 800,
940            "height": 600,
941            "title": "Heatmap",
942            "x_label": "",
943            "y_label": ""
944        })
945    }
946
947    fn execute(&self, data: VisualizationData, config: PluginConfig) -> Result<PluginResult> {
948        let (rows, cols, flat_values) = match &data {
949            VisualizationData::Array2D(v) => {
950                let r = v.len();
951                let c = v.first().map(|row| row.len()).unwrap_or(0);
952                let flat: Vec<f64> = v.iter().flat_map(|row| row.iter().copied()).collect();
953                (r, c, flat)
954            },
955            VisualizationData::Tensor { shape, data } if shape.len() == 2 => {
956                (shape[0], shape[1], data.clone())
957            },
958            _ => anyhow::bail!("Heatmap requires 2D data"),
959        };
960
961        let bytes = match config.output_format {
962            OutputFormat::Png => {
963                anyhow::bail!(
964                    "PNG output is not supported (requires binary encoder); use Svg instead"
965                )
966            },
967            OutputFormat::Svg => {
968                let svg = svg_render::heatmap(rows, cols, &flat_values, &config);
969                svg.into_bytes()
970            },
971            OutputFormat::Html => {
972                let svg = svg_render::heatmap(rows, cols, &flat_values, &config);
973                svg_render::wrap_html(&svg).into_bytes()
974            },
975            OutputFormat::Text => format!("Heatmap {}x{}", rows, cols).into_bytes(),
976            OutputFormat::Json => {
977                let cells: Vec<serde_json::Value> = flat_values
978                    .iter()
979                    .enumerate()
980                    .map(|(i, v)| {
981                        serde_json::json!({
982                            "row": i / cols.max(1),
983                            "col": i % cols.max(1),
984                            "value": v,
985                        })
986                    })
987                    .collect();
988                serde_json::to_string_pretty(&serde_json::json!({
989                    "type": "heatmap",
990                    "rows": rows,
991                    "cols": cols,
992                    "cells": cells,
993                }))
994                .unwrap_or_else(|_| "{}".to_string())
995                .into_bytes()
996            },
997            OutputFormat::Csv => {
998                let mut out = String::from("row,col,value\n");
999                for (i, v) in flat_values.iter().enumerate() {
1000                    let r = i / cols.max(1);
1001                    let c = i % cols.max(1);
1002                    out.push_str(&format!("{},{},{}\n", r, c, v));
1003                }
1004                out.into_bytes()
1005            },
1006        };
1007
1008        let out_path_str = if let Some(ref path) = config.output_path {
1009            write_output_path(path, &bytes)?;
1010            Some(path.clone())
1011        } else {
1012            None
1013        };
1014
1015        Ok(PluginResult {
1016            success: true,
1017            output_path: out_path_str,
1018            output_data: Some(bytes),
1019            metadata: {
1020                let mut m = HashMap::new();
1021                m.insert("rows".to_string(), rows.to_string());
1022                m.insert("cols".to_string(), cols.to_string());
1023                m
1024            },
1025            error: None,
1026        })
1027    }
1028
1029    fn validate(&self, data: &VisualizationData) -> bool {
1030        match data {
1031            VisualizationData::Array2D(_) => true,
1032            VisualizationData::Tensor { shape, .. } => shape.len() == 2,
1033            _ => false,
1034        }
1035    }
1036}
1037
1038/// Line plot visualization plugin
1039struct LinePlotPlugin;
1040
1041impl VisualizationPlugin for LinePlotPlugin {
1042    fn metadata(&self) -> PluginMetadata {
1043        PluginMetadata {
1044            name: "lineplot".to_string(),
1045            version: "1.0.0".to_string(),
1046            description: "Generates line plots for time series data".to_string(),
1047            author: "TrustformeRS".to_string(),
1048            supported_inputs: vec!["TimeSeries".to_string(), "Array1D".to_string()],
1049            supported_outputs: vec![
1050                OutputFormat::Svg,
1051                OutputFormat::Html,
1052                OutputFormat::Text,
1053                OutputFormat::Json,
1054                OutputFormat::Csv,
1055            ],
1056            tags: vec!["timeseries".to_string(), "trend".to_string()],
1057        }
1058    }
1059
1060    fn config_schema(&self) -> serde_json::Value {
1061        serde_json::json!({
1062            "width": 800,
1063            "height": 600,
1064            "title": "Line Plot",
1065            "x_label": "X",
1066            "y_label": "Y"
1067        })
1068    }
1069
1070    fn execute(&self, data: VisualizationData, config: PluginConfig) -> Result<PluginResult> {
1071        let points: Vec<(f64, f64)> = match &data {
1072            VisualizationData::TimeSeries {
1073                timestamps, values, ..
1074            } => timestamps.iter().zip(values.iter()).map(|(t, v)| (*t, *v)).collect(),
1075            VisualizationData::Array1D(v) => {
1076                v.iter().enumerate().map(|(i, val)| (i as f64, *val)).collect()
1077            },
1078            _ => anyhow::bail!("Line plot requires time series or 1D array data"),
1079        };
1080
1081        let n_points = points.len();
1082
1083        let bytes = match config.output_format {
1084            OutputFormat::Png => {
1085                anyhow::bail!(
1086                    "PNG output is not supported (requires binary encoder); use Svg instead"
1087                )
1088            },
1089            OutputFormat::Svg => {
1090                let svg = svg_render::line_plot(&points, &config);
1091                svg.into_bytes()
1092            },
1093            OutputFormat::Html => {
1094                let svg = svg_render::line_plot(&points, &config);
1095                svg_render::wrap_html(&svg).into_bytes()
1096            },
1097            OutputFormat::Text => format!("Line plot with {} points", n_points).into_bytes(),
1098            OutputFormat::Json => svg_render::points_json("lineplot", &points).into_bytes(),
1099            OutputFormat::Csv => svg_render::points_csv(&points).into_bytes(),
1100        };
1101
1102        let out_path_str = if let Some(ref path) = config.output_path {
1103            write_output_path(path, &bytes)?;
1104            Some(path.clone())
1105        } else {
1106            None
1107        };
1108
1109        Ok(PluginResult {
1110            success: true,
1111            output_path: out_path_str,
1112            output_data: Some(bytes),
1113            metadata: {
1114                let mut m = HashMap::new();
1115                m.insert("points".to_string(), n_points.to_string());
1116                m
1117            },
1118            error: None,
1119        })
1120    }
1121
1122    fn validate(&self, data: &VisualizationData) -> bool {
1123        matches!(
1124            data,
1125            VisualizationData::TimeSeries { .. } | VisualizationData::Array1D(_)
1126        )
1127    }
1128}
1129
1130/// Scatter plot visualization plugin
1131struct ScatterPlotPlugin;
1132
1133impl VisualizationPlugin for ScatterPlotPlugin {
1134    fn metadata(&self) -> PluginMetadata {
1135        PluginMetadata {
1136            name: "scatterplot".to_string(),
1137            version: "1.0.0".to_string(),
1138            description: "Generates scatter plots for 2D point data".to_string(),
1139            author: "TrustformeRS".to_string(),
1140            supported_inputs: vec!["Array2D".to_string()],
1141            supported_outputs: vec![
1142                OutputFormat::Svg,
1143                OutputFormat::Html,
1144                OutputFormat::Text,
1145                OutputFormat::Json,
1146                OutputFormat::Csv,
1147            ],
1148            tags: vec!["correlation".to_string(), "distribution".to_string()],
1149        }
1150    }
1151
1152    fn config_schema(&self) -> serde_json::Value {
1153        serde_json::json!({
1154            "width": 800,
1155            "height": 600,
1156            "title": "Scatter Plot",
1157            "x_label": "X",
1158            "y_label": "Y"
1159        })
1160    }
1161
1162    fn execute(&self, data: VisualizationData, config: PluginConfig) -> Result<PluginResult> {
1163        let points: Vec<(f64, f64)> = match &data {
1164            VisualizationData::Array2D(v) => v
1165                .iter()
1166                .filter_map(
1167                    |row| {
1168                        if row.len() >= 2 {
1169                            Some((row[0], row[1]))
1170                        } else {
1171                            None
1172                        }
1173                    },
1174                )
1175                .collect(),
1176            _ => anyhow::bail!("Scatter plot requires 2D array data (each row = [x, y])"),
1177        };
1178
1179        let n_points = points.len();
1180
1181        let bytes = match config.output_format {
1182            OutputFormat::Png => {
1183                anyhow::bail!(
1184                    "PNG output is not supported (requires binary encoder); use Svg instead"
1185                )
1186            },
1187            OutputFormat::Svg => {
1188                let svg = svg_render::scatter(&points, &config);
1189                svg.into_bytes()
1190            },
1191            OutputFormat::Html => {
1192                let svg = svg_render::scatter(&points, &config);
1193                svg_render::wrap_html(&svg).into_bytes()
1194            },
1195            OutputFormat::Text => format!("Scatter plot with {} points", n_points).into_bytes(),
1196            OutputFormat::Json => svg_render::points_json("scatterplot", &points).into_bytes(),
1197            OutputFormat::Csv => svg_render::points_csv(&points).into_bytes(),
1198        };
1199
1200        let out_path_str = if let Some(ref path) = config.output_path {
1201            write_output_path(path, &bytes)?;
1202            Some(path.clone())
1203        } else {
1204            None
1205        };
1206
1207        Ok(PluginResult {
1208            success: true,
1209            output_path: out_path_str,
1210            output_data: Some(bytes),
1211            metadata: {
1212                let mut m = HashMap::new();
1213                m.insert("points".to_string(), n_points.to_string());
1214                m
1215            },
1216            error: None,
1217        })
1218    }
1219
1220    fn validate(&self, data: &VisualizationData) -> bool {
1221        matches!(data, VisualizationData::Array2D(_))
1222    }
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227    use super::*;
1228
1229    // -----------------------------------------------------------------------
1230    // Existing tests (updated for Svg default)
1231    // -----------------------------------------------------------------------
1232
1233    #[test]
1234    fn test_plugin_manager_creation() {
1235        let manager = PluginManager::new();
1236        let plugins = manager.list_plugins();
1237        assert!(!plugins.is_empty());
1238    }
1239
1240    #[test]
1241    fn test_histogram_plugin() {
1242        let manager = PluginManager::new();
1243        let data = VisualizationData::Array1D(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
1244        let config = PluginConfig::default(); // default is now Svg
1245
1246        let result = manager.execute("histogram", data, config).expect("operation failed in test");
1247
1248        assert!(result.success);
1249        assert!(result.output_data.is_some());
1250    }
1251
1252    #[test]
1253    fn test_plugin_validation() {
1254        let manager = PluginManager::new();
1255
1256        // Valid data for histogram
1257        let data = VisualizationData::Array1D(vec![1.0, 2.0, 3.0]);
1258        let config = PluginConfig::default();
1259        assert!(manager.execute("histogram", data, config.clone()).is_ok());
1260
1261        // Invalid data for heatmap (needs 2D)
1262        let data = VisualizationData::Array1D(vec![1.0, 2.0, 3.0]);
1263        assert!(manager.execute("heatmap", data, config).is_err());
1264    }
1265
1266    #[test]
1267    fn test_custom_plugin_registration() {
1268        let manager = PluginManager::new();
1269        let count_before = manager.list_plugins().len();
1270
1271        // Register histogram again (should replace)
1272        manager
1273            .register_plugin(Box::new(HistogramPlugin))
1274            .expect("operation failed in test");
1275
1276        let count_after = manager.list_plugins().len();
1277        assert_eq!(count_before, count_after);
1278    }
1279
1280    // -----------------------------------------------------------------------
1281    // New SVG rendering tests
1282    // -----------------------------------------------------------------------
1283
1284    fn svg_config() -> PluginConfig {
1285        PluginConfig {
1286            output_format: OutputFormat::Svg,
1287            ..PluginConfig::default()
1288        }
1289    }
1290
1291    #[test]
1292    fn test_histogram_svg_contains_rect() {
1293        let plugin = HistogramPlugin;
1294        let data = VisualizationData::Array1D(vec![1.0, 2.0, 2.5, 3.0, 4.0, 5.0, 5.5]);
1295        let result = plugin.execute(data, svg_config()).expect("histogram SVG render failed");
1296        assert!(result.success);
1297        let bytes = result.output_data.expect("no output data");
1298        let svg = String::from_utf8(bytes).expect("invalid UTF-8");
1299        assert!(
1300            svg.contains("<rect"),
1301            "SVG histogram should contain <rect elements; got: {}",
1302            &svg[..svg.len().min(300)]
1303        );
1304        assert!(svg.starts_with("<svg"), "output should start with <svg tag");
1305    }
1306
1307    #[test]
1308    fn test_heatmap_svg_contains_rect_cells() {
1309        let plugin = HeatmapPlugin;
1310        let data = VisualizationData::Array2D(vec![
1311            vec![1.0, 2.0, 3.0],
1312            vec![4.0, 5.0, 6.0],
1313            vec![7.0, 8.0, 9.0],
1314        ]);
1315        let result = plugin.execute(data, svg_config()).expect("heatmap SVG render failed");
1316        assert!(result.success);
1317        let bytes = result.output_data.expect("no output data");
1318        let svg = String::from_utf8(bytes).expect("invalid UTF-8");
1319        assert!(
1320            svg.contains("<rect"),
1321            "SVG heatmap should contain <rect cell elements"
1322        );
1323        assert!(svg.starts_with("<svg"));
1324    }
1325
1326    #[test]
1327    fn test_line_plot_svg_contains_polyline() {
1328        let plugin = LinePlotPlugin;
1329        let data = VisualizationData::TimeSeries {
1330            timestamps: vec![0.0, 1.0, 2.0, 3.0, 4.0],
1331            values: vec![0.1, 0.4, 0.9, 0.3, 0.7],
1332            labels: vec![],
1333        };
1334        let result = plugin.execute(data, svg_config()).expect("line plot SVG render failed");
1335        assert!(result.success);
1336        let bytes = result.output_data.expect("no output data");
1337        let svg = String::from_utf8(bytes).expect("invalid UTF-8");
1338        assert!(
1339            svg.contains("<polyline"),
1340            "SVG line plot should contain <polyline element"
1341        );
1342        assert!(svg.starts_with("<svg"));
1343    }
1344
1345    #[test]
1346    fn test_scatter_svg_contains_circle() {
1347        let plugin = ScatterPlotPlugin;
1348        let data = VisualizationData::Array2D(vec![
1349            vec![1.0, 2.0],
1350            vec![3.0, 4.0],
1351            vec![5.0, 6.0],
1352            vec![7.0, 8.0],
1353        ]);
1354        let result = plugin.execute(data, svg_config()).expect("scatter plot SVG render failed");
1355        assert!(result.success);
1356        let bytes = result.output_data.expect("no output data");
1357        let svg = String::from_utf8(bytes).expect("invalid UTF-8");
1358        assert!(
1359            svg.contains("<circle"),
1360            "SVG scatter plot should contain <circle elements"
1361        );
1362        assert!(svg.starts_with("<svg"));
1363    }
1364
1365    #[test]
1366    fn test_output_path_writes_file() {
1367        let plugin = HistogramPlugin;
1368        let data = VisualizationData::Array1D(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
1369
1370        let tmp_dir = std::env::temp_dir();
1371        let out_file = tmp_dir.join("test_histogram_visp.svg");
1372        let out_path_str = out_file.to_str().expect("temp path is not valid UTF-8").to_string();
1373
1374        let config = PluginConfig {
1375            output_format: OutputFormat::Svg,
1376            output_path: Some(out_path_str.clone()),
1377            ..PluginConfig::default()
1378        };
1379
1380        let result = plugin.execute(data, config).expect("histogram with output_path failed");
1381        assert!(result.success);
1382        assert_eq!(result.output_path.as_deref(), Some(out_path_str.as_str()));
1383
1384        let written = std::fs::read_to_string(&out_file).expect("output file not found on disk");
1385        assert!(
1386            written.contains("<rect"),
1387            "Written SVG file should contain <rect"
1388        );
1389
1390        // Cleanup
1391        std::fs::remove_file(&out_file).ok();
1392    }
1393
1394    #[test]
1395    fn test_png_returns_error() {
1396        let plugin = HistogramPlugin;
1397        let data = VisualizationData::Array1D(vec![1.0, 2.0, 3.0]);
1398        let config = PluginConfig {
1399            output_format: OutputFormat::Png,
1400            ..PluginConfig::default()
1401        };
1402        let result = plugin.execute(data, config);
1403        assert!(result.is_err(), "PNG format should return an error, not Ok");
1404        let err = result.unwrap_err();
1405        let msg = err.to_string().to_lowercase();
1406        assert!(
1407            msg.contains("png") || msg.contains("not supported"),
1408            "Error message should mention PNG or not supported; got: {}",
1409            err
1410        );
1411    }
1412
1413    #[test]
1414    fn test_html_wraps_svg() {
1415        let plugin = ScatterPlotPlugin;
1416        let data = VisualizationData::Array2D(vec![vec![0.0, 1.0], vec![2.0, 3.0]]);
1417        let config = PluginConfig {
1418            output_format: OutputFormat::Html,
1419            ..PluginConfig::default()
1420        };
1421        let result = plugin.execute(data, config).expect("HTML render failed");
1422        let bytes = result.output_data.expect("no output data");
1423        let html = String::from_utf8(bytes).expect("invalid UTF-8");
1424        assert!(
1425            html.contains("<!DOCTYPE html>"),
1426            "HTML output should contain DOCTYPE"
1427        );
1428        assert!(html.contains("<svg"), "HTML output should embed SVG");
1429    }
1430
1431    #[test]
1432    fn test_histogram_json_output() {
1433        let plugin = HistogramPlugin;
1434        let data = VisualizationData::Array1D(vec![1.0, 2.0, 3.0, 4.0]);
1435        let config = PluginConfig {
1436            output_format: OutputFormat::Json,
1437            ..PluginConfig::default()
1438        };
1439        let result = plugin.execute(data, config).expect("JSON render failed");
1440        let bytes = result.output_data.expect("no output data");
1441        let json_str = String::from_utf8(bytes).expect("invalid UTF-8");
1442        let parsed: serde_json::Value =
1443            serde_json::from_str(&json_str).expect("output is not valid JSON");
1444        assert_eq!(parsed["type"], "histogram");
1445        assert!(parsed["bins"].is_array());
1446    }
1447
1448    #[test]
1449    fn test_histogram_csv_output() {
1450        let plugin = HistogramPlugin;
1451        let data = VisualizationData::Array1D(vec![1.0, 2.0, 3.0, 4.0]);
1452        let config = PluginConfig {
1453            output_format: OutputFormat::Csv,
1454            ..PluginConfig::default()
1455        };
1456        let result = plugin.execute(data, config).expect("CSV render failed");
1457        let bytes = result.output_data.expect("no output data");
1458        let csv_str = String::from_utf8(bytes).expect("invalid UTF-8");
1459        assert!(
1460            csv_str.starts_with("bin_left,bin_right,count"),
1461            "CSV should start with header"
1462        );
1463    }
1464
1465    #[test]
1466    fn test_config_schema_fields() {
1467        let hist_schema = HistogramPlugin.config_schema();
1468        assert!(hist_schema["width"].is_number());
1469        assert!(hist_schema["height"].is_number());
1470
1471        let heat_schema = HeatmapPlugin.config_schema();
1472        assert!(heat_schema["width"].is_number());
1473
1474        let line_schema = LinePlotPlugin.config_schema();
1475        assert!(line_schema["x_label"].is_string());
1476
1477        let scatter_schema = ScatterPlotPlugin.config_schema();
1478        assert!(scatter_schema["title"].is_string());
1479    }
1480}