1use serde::{Deserialize, Serialize};
8use std::fmt;
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12#[serde(tag = "type", rename_all = "snake_case")]
13pub enum ContentNode {
14 Text(StyledText),
16 Table(ContentTable),
18 Code {
20 language: Option<String>,
21 source: String,
22 },
23 Chart(ChartSpec),
25 KeyValue(Vec<(String, ContentNode)>),
27 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 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub column_types: Option<Vec<String>>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub total_rows: Option<usize>,
95 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120pub struct ChartSpec {
121 pub chart_type: ChartType,
122 pub channels: Vec<ChartChannel>,
124 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub echarts_options: Option<serde_json::Value>,
140 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168pub struct ChartChannel {
169 pub name: String,
171 pub label: String,
173 pub values: Vec<f64>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub color: Option<Color>,
177}
178
179impl ChartType {
180 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 pub fn channel(&self, name: &str) -> Option<&ChartChannel> {
198 self.channels.iter().find(|c| c.name == name)
199 }
200
201 pub fn channels_by_name(&self, name: &str) -> Vec<&ChartChannel> {
203 self.channels.iter().filter(|c| c.name == name).collect()
204 }
205
206 pub fn data_len(&self) -> usize {
208 self.channels.first().map(|c| c.values.len()).unwrap_or(0)
209 }
210}
211
212pub type ChartSeries = ChartChannel;
216
217impl 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 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 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
262impl ContentNode {
265 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 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 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 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 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 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 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 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(),
729 vec![(1.0, 100.0), (2.0, 200.0)],
730 None,
731 )],
732 );
733 assert_eq!(spec.channels.len(), 2); assert_eq!(spec.channel("x").unwrap().values, vec![1.0, 2.0]);
735 assert_eq!(spec.channels_by_name("y")[0].label, "Revenue");
736 assert_eq!(spec.channels_by_name("y")[0].values, vec![100.0, 200.0]);
737 }
738
739 #[test]
740 fn test_content_node_serde_roundtrip() {
741 let node = ContentNode::Chart(ChartSpec {
742 chart_type: ChartType::Line,
743 channels: vec![ChartChannel {
744 name: "y".into(),
745 label: "Price".into(),
746 values: vec![1.0, 2.0],
747 color: Some(Color::Named(NamedColor::Red)),
748 }],
749 x_categories: None,
750 title: Some("Test".into()),
751 x_label: None,
752 y_label: None,
753 width: None,
754 height: None,
755 echarts_options: None,
756 interactive: true,
757 });
758 let json = serde_json::to_string(&node).unwrap();
759 let roundtrip: ContentNode = serde_json::from_str(&json).unwrap();
760 assert_eq!(node, roundtrip);
761 }
762
763 #[test]
764 fn test_content_table_serde_roundtrip() {
765 let node = ContentNode::Table(ContentTable {
766 headers: vec!["A".into()],
767 rows: vec![vec![ContentNode::plain("1")]],
768 border: BorderStyle::Rounded,
769 max_rows: None,
770 column_types: None,
771 total_rows: None,
772 sortable: false,
773 });
774 let json = serde_json::to_string(&node).unwrap();
775 let roundtrip: ContentNode = serde_json::from_str(&json).unwrap();
776 assert_eq!(node, roundtrip);
777 }
778}