Skip to main content

shape_value/
content.rs

1//! Structured content nodes — the output of Content.render().
2//!
3//! ContentNode is a rich, structured representation of rendered output that
4//! supports styled text, tables, code blocks, charts, key-value pairs, and
5//! fragments (compositions of multiple nodes).
6
7use serde::{Deserialize, Serialize};
8use std::fmt;
9
10/// A structured content node — the output of Content.render()
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12#[serde(tag = "type", rename_all = "snake_case")]
13pub enum ContentNode {
14    /// Styled text with spans
15    Text(StyledText),
16    /// Table with headers, rows, and optional styling
17    Table(ContentTable),
18    /// Code block with optional language
19    Code {
20        language: Option<String>,
21        source: String,
22    },
23    /// Chart specification
24    Chart(ChartSpec),
25    /// Key-value pairs
26    KeyValue(Vec<(String, ContentNode)>),
27    /// Composition of multiple content nodes
28    Fragment(Vec<ContentNode>),
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct StyledText {
33    pub spans: Vec<StyledSpan>,
34}
35
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct StyledSpan {
38    pub text: String,
39    pub style: Style,
40}
41
42#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
43pub struct Style {
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub fg: Option<Color>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub bg: Option<Color>,
48    #[serde(default, skip_serializing_if = "is_false")]
49    pub bold: bool,
50    #[serde(default, skip_serializing_if = "is_false")]
51    pub italic: bool,
52    #[serde(default, skip_serializing_if = "is_false")]
53    pub underline: bool,
54    #[serde(default, skip_serializing_if = "is_false")]
55    pub dim: bool,
56}
57
58fn is_false(v: &bool) -> bool {
59    !v
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum Color {
65    Named(NamedColor),
66    Rgb(u8, u8, u8),
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum NamedColor {
72    Red,
73    Green,
74    Blue,
75    Yellow,
76    Magenta,
77    Cyan,
78    White,
79    Default,
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct ContentTable {
84    pub headers: Vec<String>,
85    pub rows: Vec<Vec<ContentNode>>,
86    pub border: BorderStyle,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub max_rows: Option<usize>,
89    /// Column type hints: "string", "number", "date", etc.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub column_types: Option<Vec<String>>,
92    /// Total row count before truncation (for display: "showing 50 of 1000").
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub total_rows: Option<usize>,
95    /// Whether interactive renderers should enable column sorting.
96    #[serde(default)]
97    pub sortable: bool,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum BorderStyle {
103    Rounded,
104    Sharp,
105    Heavy,
106    Double,
107    Minimal,
108    None,
109}
110
111impl Default for BorderStyle {
112    fn default() -> Self {
113        BorderStyle::Rounded
114    }
115}
116
117// ========== Chart types ==========
118
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120pub struct ChartSpec {
121    pub chart_type: ChartType,
122    /// Data channels populated by typed builder methods (.x(), .y(), .open(), etc.)
123    pub channels: Vec<ChartChannel>,
124    /// Categorical x-axis labels (bar charts, box plots)
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub x_categories: Option<Vec<String>>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub title: Option<String>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub x_label: Option<String>,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub y_label: Option<String>,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub width: Option<usize>,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub height: Option<usize>,
137    /// Full ECharts option JSON override (injected by chart detection).
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub echarts_options: Option<serde_json::Value>,
140    /// Whether this chart should be rendered interactively (default true).
141    #[serde(default = "default_true")]
142    pub interactive: bool,
143}
144
145fn default_true() -> bool {
146    true
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum ChartType {
152    Line,
153    Bar,
154    Scatter,
155    Area,
156    Candlestick,
157    Histogram,
158    BoxPlot,
159    Heatmap,
160    Bubble,
161}
162
163/// A named data channel in a chart (e.g. "x", "y", "open", "high", "low", "close").
164///
165/// Channel names are internal — Shape users call typed methods like `.x()`, `.y()`,
166/// never write string keys directly.
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168pub struct ChartChannel {
169    /// Role name — set by builder method, never by user (e.g. "x", "y", "open")
170    pub name: String,
171    /// Display label
172    pub label: String,
173    /// Extracted numeric data
174    pub values: Vec<f64>,
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub color: Option<Color>,
177}
178
179impl ChartType {
180    /// Returns the channel names required for this chart type.
181    pub fn required_channels(&self) -> &[&str] {
182        match self {
183            ChartType::Line | ChartType::Area => &["x", "y"],
184            ChartType::Bar => &["y"],
185            ChartType::Scatter => &["x", "y"],
186            ChartType::Candlestick => &["x", "open", "high", "low", "close"],
187            ChartType::BoxPlot => &["x", "min", "q1", "median", "q3", "max"],
188            ChartType::Histogram => &["values"],
189            ChartType::Heatmap => &["x", "y", "value"],
190            ChartType::Bubble => &["x", "y", "size"],
191        }
192    }
193}
194
195impl ChartSpec {
196    /// Get a channel by role name.
197    pub fn channel(&self, name: &str) -> Option<&ChartChannel> {
198        self.channels.iter().find(|c| c.name == name)
199    }
200
201    /// Get all channels with a given role name (e.g. multiple "y" channels).
202    pub fn channels_by_name(&self, name: &str) -> Vec<&ChartChannel> {
203        self.channels.iter().filter(|c| c.name == name).collect()
204    }
205
206    /// Number of data points (from the first channel).
207    pub fn data_len(&self) -> usize {
208        self.channels.first().map(|c| c.values.len()).unwrap_or(0)
209    }
210}
211
212// ========== Backward compatibility ==========
213
214/// Backward-compatible type alias. New code should use `ChartChannel` directly.
215pub type ChartSeries = ChartChannel;
216
217/// Helper to create a ChartSpec from legacy series data (Vec<(f64, f64)> pairs).
218///
219/// Converts old-style `ChartSeries { label, data: Vec<(f64, f64)>, color }` into
220/// channel-based representation with "x" and "y" channels.
221impl ChartSpec {
222    pub fn from_series(
223        chart_type: ChartType,
224        series: Vec<(String, Vec<(f64, f64)>, Option<Color>)>,
225    ) -> Self {
226        let mut channels = Vec::new();
227        if let Some(first) = series.first() {
228            // x channel from first series
229            let x_values: Vec<f64> = first.1.iter().map(|(x, _)| *x).collect();
230            channels.push(ChartChannel {
231                name: "x".to_string(),
232                label: "x".to_string(),
233                values: x_values,
234                color: None,
235            });
236        }
237        // y channels from each series
238        for (label, data, color) in &series {
239            let y_values: Vec<f64> = data.iter().map(|(_, y)| *y).collect();
240            channels.push(ChartChannel {
241                name: "y".to_string(),
242                label: label.clone(),
243                values: y_values,
244                color: color.clone(),
245            });
246        }
247        ChartSpec {
248            chart_type,
249            channels,
250            x_categories: None,
251            title: None,
252            x_label: None,
253            y_label: None,
254            width: None,
255            height: None,
256            echarts_options: None,
257            interactive: true,
258        }
259    }
260}
261
262// ========== ContentNode helpers ==========
263
264impl ContentNode {
265    /// Create a plain text node.
266    pub fn plain(text: impl Into<String>) -> Self {
267        ContentNode::Text(StyledText {
268            spans: vec![StyledSpan {
269                text: text.into(),
270                style: Style::default(),
271            }],
272        })
273    }
274
275    /// Create a styled text node.
276    pub fn styled(text: impl Into<String>, style: Style) -> Self {
277        ContentNode::Text(StyledText {
278            spans: vec![StyledSpan {
279                text: text.into(),
280                style,
281            }],
282        })
283    }
284
285    /// Apply foreground color to this node.
286    pub fn with_fg(self, color: Color) -> Self {
287        match self {
288            ContentNode::Text(mut st) => {
289                for span in &mut st.spans {
290                    span.style.fg = Some(color.clone());
291                }
292                ContentNode::Text(st)
293            }
294            other => other,
295        }
296    }
297
298    /// Apply background color.
299    pub fn with_bg(self, color: Color) -> Self {
300        match self {
301            ContentNode::Text(mut st) => {
302                for span in &mut st.spans {
303                    span.style.bg = Some(color.clone());
304                }
305                ContentNode::Text(st)
306            }
307            other => other,
308        }
309    }
310
311    /// Apply bold.
312    pub fn with_bold(self) -> Self {
313        match self {
314            ContentNode::Text(mut st) => {
315                for span in &mut st.spans {
316                    span.style.bold = true;
317                }
318                ContentNode::Text(st)
319            }
320            other => other,
321        }
322    }
323
324    /// Apply italic.
325    pub fn with_italic(self) -> Self {
326        match self {
327            ContentNode::Text(mut st) => {
328                for span in &mut st.spans {
329                    span.style.italic = true;
330                }
331                ContentNode::Text(st)
332            }
333            other => other,
334        }
335    }
336
337    /// Apply underline.
338    pub fn with_underline(self) -> Self {
339        match self {
340            ContentNode::Text(mut st) => {
341                for span in &mut st.spans {
342                    span.style.underline = true;
343                }
344                ContentNode::Text(st)
345            }
346            other => other,
347        }
348    }
349
350    /// Apply dim.
351    pub fn with_dim(self) -> Self {
352        match self {
353            ContentNode::Text(mut st) => {
354                for span in &mut st.spans {
355                    span.style.dim = true;
356                }
357                ContentNode::Text(st)
358            }
359            other => other,
360        }
361    }
362}
363
364impl fmt::Display for ContentNode {
365    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
366        match self {
367            ContentNode::Text(st) => {
368                for span in &st.spans {
369                    write!(f, "{}", span.text)?;
370                }
371                Ok(())
372            }
373            ContentNode::Table(table) => {
374                if !table.headers.is_empty() {
375                    for (i, header) in table.headers.iter().enumerate() {
376                        if i > 0 {
377                            write!(f, " | ")?;
378                        }
379                        write!(f, "{}", header)?;
380                    }
381                    writeln!(f)?;
382                    for (i, _) in table.headers.iter().enumerate() {
383                        if i > 0 {
384                            write!(f, "-+-")?;
385                        }
386                        write!(f, "---")?;
387                    }
388                    writeln!(f)?;
389                }
390                let limit = table.max_rows.unwrap_or(table.rows.len());
391                for row in table.rows.iter().take(limit) {
392                    for (i, cell) in row.iter().enumerate() {
393                        if i > 0 {
394                            write!(f, " | ")?;
395                        }
396                        write!(f, "{}", cell)?;
397                    }
398                    writeln!(f)?;
399                }
400                Ok(())
401            }
402            ContentNode::Code { source, .. } => write!(f, "{}", source),
403            ContentNode::Chart(spec) => {
404                write!(
405                    f,
406                    "[Chart: {}]",
407                    spec.title.as_deref().unwrap_or("untitled")
408                )
409            }
410            ContentNode::KeyValue(pairs) => {
411                for (i, (key, value)) in pairs.iter().enumerate() {
412                    if i > 0 {
413                        writeln!(f)?;
414                    }
415                    write!(f, "{}: {}", key, value)?;
416                }
417                Ok(())
418            }
419            ContentNode::Fragment(parts) => {
420                for part in parts {
421                    write!(f, "{}", part)?;
422                }
423                Ok(())
424            }
425        }
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn test_plain_text_node() {
435        let node = ContentNode::plain("hello world");
436        match &node {
437            ContentNode::Text(st) => {
438                assert_eq!(st.spans.len(), 1);
439                assert_eq!(st.spans[0].text, "hello world");
440                assert_eq!(st.spans[0].style, Style::default());
441            }
442            _ => panic!("expected Text variant"),
443        }
444    }
445
446    #[test]
447    fn test_styled_text_node() {
448        let style = Style {
449            bold: true,
450            fg: Some(Color::Named(NamedColor::Red)),
451            ..Default::default()
452        };
453        let node = ContentNode::styled("warning", style.clone());
454        match &node {
455            ContentNode::Text(st) => {
456                assert_eq!(st.spans.len(), 1);
457                assert_eq!(st.spans[0].text, "warning");
458                assert_eq!(st.spans[0].style, style);
459            }
460            _ => panic!("expected Text variant"),
461        }
462    }
463
464    #[test]
465    fn test_content_node_display() {
466        assert_eq!(ContentNode::plain("hello").to_string(), "hello");
467
468        let code = ContentNode::Code {
469            language: Some("rust".into()),
470            source: "fn main() {}".into(),
471        };
472        assert_eq!(code.to_string(), "fn main() {}");
473
474        let chart = ContentNode::Chart(ChartSpec {
475            chart_type: ChartType::Line,
476            channels: vec![],
477            x_categories: None,
478            title: Some("My Chart".into()),
479            x_label: None,
480            y_label: None,
481            width: None,
482            height: None,
483            echarts_options: None,
484            interactive: true,
485        });
486        assert_eq!(chart.to_string(), "[Chart: My Chart]");
487
488        let chart_no_title = ContentNode::Chart(ChartSpec {
489            chart_type: ChartType::Bar,
490            channels: vec![],
491            x_categories: None,
492            title: None,
493            x_label: None,
494            y_label: None,
495            width: None,
496            height: None,
497            echarts_options: None,
498            interactive: true,
499        });
500        assert_eq!(chart_no_title.to_string(), "[Chart: untitled]");
501    }
502
503    #[test]
504    fn test_with_fg_color() {
505        let node = ContentNode::plain("text").with_fg(Color::Named(NamedColor::Green));
506        match &node {
507            ContentNode::Text(st) => {
508                assert_eq!(st.spans[0].style.fg, Some(Color::Named(NamedColor::Green)));
509            }
510            _ => panic!("expected Text variant"),
511        }
512    }
513
514    #[test]
515    fn test_with_bold() {
516        let node = ContentNode::plain("text").with_bold();
517        match &node {
518            ContentNode::Text(st) => {
519                assert!(st.spans[0].style.bold);
520            }
521            _ => panic!("expected Text variant"),
522        }
523    }
524
525    #[test]
526    fn test_with_italic() {
527        let node = ContentNode::plain("text").with_italic();
528        match &node {
529            ContentNode::Text(st) => {
530                assert!(st.spans[0].style.italic);
531            }
532            _ => panic!("expected Text variant"),
533        }
534    }
535
536    #[test]
537    fn test_with_underline() {
538        let node = ContentNode::plain("text").with_underline();
539        match &node {
540            ContentNode::Text(st) => {
541                assert!(st.spans[0].style.underline);
542            }
543            _ => panic!("expected Text variant"),
544        }
545    }
546
547    #[test]
548    fn test_with_dim() {
549        let node = ContentNode::plain("text").with_dim();
550        match &node {
551            ContentNode::Text(st) => {
552                assert!(st.spans[0].style.dim);
553            }
554            _ => panic!("expected Text variant"),
555        }
556    }
557
558    #[test]
559    fn test_with_bg_color() {
560        let node = ContentNode::plain("text").with_bg(Color::Rgb(255, 0, 0));
561        match &node {
562            ContentNode::Text(st) => {
563                assert_eq!(st.spans[0].style.bg, Some(Color::Rgb(255, 0, 0)));
564            }
565            _ => panic!("expected Text variant"),
566        }
567    }
568
569    #[test]
570    fn test_style_chaining() {
571        let node = ContentNode::plain("text")
572            .with_bold()
573            .with_fg(Color::Named(NamedColor::Cyan))
574            .with_underline();
575        match &node {
576            ContentNode::Text(st) => {
577                assert!(st.spans[0].style.bold);
578                assert!(st.spans[0].style.underline);
579                assert_eq!(st.spans[0].style.fg, Some(Color::Named(NamedColor::Cyan)));
580            }
581            _ => panic!("expected Text variant"),
582        }
583    }
584
585    #[test]
586    fn test_non_text_node_style_passthrough() {
587        let code = ContentNode::Code {
588            language: None,
589            source: "x = 1".into(),
590        };
591        let result = code.with_bold();
592        match &result {
593            ContentNode::Code { source, .. } => assert_eq!(source, "x = 1"),
594            _ => panic!("expected Code variant"),
595        }
596    }
597
598    #[test]
599    fn test_fragment_composition() {
600        let frag = ContentNode::Fragment(vec![
601            ContentNode::plain("hello "),
602            ContentNode::plain("world"),
603        ]);
604        assert_eq!(frag.to_string(), "hello world");
605    }
606
607    #[test]
608    fn test_key_value_display() {
609        let kv = ContentNode::KeyValue(vec![
610            ("name".into(), ContentNode::plain("Alice")),
611            ("age".into(), ContentNode::plain("30")),
612        ]);
613        assert_eq!(kv.to_string(), "name: Alice\nage: 30");
614    }
615
616    #[test]
617    fn test_table_display() {
618        let table = ContentNode::Table(ContentTable {
619            headers: vec!["Name".into(), "Value".into()],
620            rows: vec![
621                vec![ContentNode::plain("a"), ContentNode::plain("1")],
622                vec![ContentNode::plain("b"), ContentNode::plain("2")],
623            ],
624            border: BorderStyle::default(),
625            max_rows: None,
626            column_types: None,
627            total_rows: None,
628            sortable: false,
629        });
630        let output = table.to_string();
631        assert!(output.contains("Name"));
632        assert!(output.contains("Value"));
633        assert!(output.contains("a"));
634        assert!(output.contains("1"));
635        assert!(output.contains("b"));
636        assert!(output.contains("2"));
637    }
638
639    #[test]
640    fn test_table_max_rows() {
641        let table = ContentNode::Table(ContentTable {
642            headers: vec!["X".into()],
643            rows: vec![
644                vec![ContentNode::plain("1")],
645                vec![ContentNode::plain("2")],
646                vec![ContentNode::plain("3")],
647            ],
648            border: BorderStyle::None,
649            max_rows: Some(2),
650            column_types: None,
651            total_rows: None,
652            sortable: false,
653        });
654        let output = table.to_string();
655        assert!(output.contains("1"));
656        assert!(output.contains("2"));
657        assert!(!output.contains("3"));
658    }
659
660    #[test]
661    fn test_content_node_equality() {
662        let a = ContentNode::plain("hello");
663        let b = ContentNode::plain("hello");
664        let c = ContentNode::plain("world");
665        assert_eq!(a, b);
666        assert_ne!(a, c);
667    }
668
669    #[test]
670    fn test_border_style_default() {
671        assert_eq!(BorderStyle::default(), BorderStyle::Rounded);
672    }
673
674    #[test]
675    fn test_chart_spec_channel_helpers() {
676        let spec = ChartSpec {
677            chart_type: ChartType::Line,
678            channels: vec![
679                ChartChannel {
680                    name: "x".into(),
681                    label: "Time".into(),
682                    values: vec![1.0, 2.0, 3.0],
683                    color: None,
684                },
685                ChartChannel {
686                    name: "y".into(),
687                    label: "Price".into(),
688                    values: vec![10.0, 20.0, 30.0],
689                    color: None,
690                },
691                ChartChannel {
692                    name: "y".into(),
693                    label: "Volume".into(),
694                    values: vec![100.0, 200.0, 300.0],
695                    color: None,
696                },
697            ],
698            x_categories: None,
699            title: None,
700            x_label: None,
701            y_label: None,
702            width: None,
703            height: None,
704            echarts_options: None,
705            interactive: true,
706        };
707        assert_eq!(spec.channel("x").unwrap().label, "Time");
708        assert_eq!(spec.channels_by_name("y").len(), 2);
709        assert_eq!(spec.data_len(), 3);
710    }
711
712    #[test]
713    fn test_chart_type_required_channels() {
714        assert_eq!(ChartType::Line.required_channels(), &["x", "y"]);
715        assert_eq!(
716            ChartType::Candlestick.required_channels(),
717            &["x", "open", "high", "low", "close"]
718        );
719        assert_eq!(ChartType::Bar.required_channels(), &["y"]);
720        assert_eq!(ChartType::Histogram.required_channels(), &["values"]);
721    }
722
723    #[test]
724    fn test_chart_spec_from_series() {
725        let spec = ChartSpec::from_series(
726            ChartType::Line,
727            vec![
728                ("Revenue".to_string(), vec![(1.0, 100.0), (2.0, 200.0)], None),
729            ],
730        );
731        assert_eq!(spec.channels.len(), 2); // x + y
732        assert_eq!(spec.channel("x").unwrap().values, vec![1.0, 2.0]);
733        assert_eq!(spec.channels_by_name("y")[0].label, "Revenue");
734        assert_eq!(spec.channels_by_name("y")[0].values, vec![100.0, 200.0]);
735    }
736
737    #[test]
738    fn test_content_node_serde_roundtrip() {
739        let node = ContentNode::Chart(ChartSpec {
740            chart_type: ChartType::Line,
741            channels: vec![ChartChannel {
742                name: "y".into(),
743                label: "Price".into(),
744                values: vec![1.0, 2.0],
745                color: Some(Color::Named(NamedColor::Red)),
746            }],
747            x_categories: None,
748            title: Some("Test".into()),
749            x_label: None,
750            y_label: None,
751            width: None,
752            height: None,
753            echarts_options: None,
754            interactive: true,
755        });
756        let json = serde_json::to_string(&node).unwrap();
757        let roundtrip: ContentNode = serde_json::from_str(&json).unwrap();
758        assert_eq!(node, roundtrip);
759    }
760
761    #[test]
762    fn test_content_table_serde_roundtrip() {
763        let node = ContentNode::Table(ContentTable {
764            headers: vec!["A".into()],
765            rows: vec![vec![ContentNode::plain("1")]],
766            border: BorderStyle::Rounded,
767            max_rows: None,
768            column_types: None,
769            total_rows: None,
770            sortable: false,
771        });
772        let json = serde_json::to_string(&node).unwrap();
773        let roundtrip: ContentNode = serde_json::from_str(&json).unwrap();
774        assert_eq!(node, roundtrip);
775    }
776}