1use std::fmt;
8
9#[derive(Debug, Clone, PartialEq)]
11pub enum ContentNode {
12 Text(StyledText),
14 Table(ContentTable),
16 Code {
18 language: Option<String>,
19 source: String,
20 },
21 Chart(ChartSpec),
23 KeyValue(Vec<(String, ContentNode)>),
25 Fragment(Vec<ContentNode>),
27}
28
29#[derive(Debug, Clone, PartialEq)]
30pub struct StyledText {
31 pub spans: Vec<StyledSpan>,
32}
33
34#[derive(Debug, Clone, PartialEq)]
35pub struct StyledSpan {
36 pub text: String,
37 pub style: Style,
38}
39
40#[derive(Debug, Clone, PartialEq, Default)]
41pub struct Style {
42 pub fg: Option<Color>,
43 pub bg: Option<Color>,
44 pub bold: bool,
45 pub italic: bool,
46 pub underline: bool,
47 pub dim: bool,
48}
49
50#[derive(Debug, Clone, PartialEq)]
51pub enum Color {
52 Named(NamedColor),
53 Rgb(u8, u8, u8),
54}
55
56#[derive(Debug, Clone, Copy, PartialEq)]
57pub enum NamedColor {
58 Red,
59 Green,
60 Blue,
61 Yellow,
62 Magenta,
63 Cyan,
64 White,
65 Default,
66}
67
68#[derive(Debug, Clone, PartialEq)]
69pub struct ContentTable {
70 pub headers: Vec<String>,
71 pub rows: Vec<Vec<ContentNode>>,
72 pub border: BorderStyle,
73 pub max_rows: Option<usize>,
74 pub column_types: Option<Vec<String>>,
76 pub total_rows: Option<usize>,
78 pub sortable: bool,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq)]
83pub enum BorderStyle {
84 Rounded,
85 Sharp,
86 Heavy,
87 Double,
88 Minimal,
89 None,
90}
91
92impl Default for BorderStyle {
93 fn default() -> Self {
94 BorderStyle::Rounded
95 }
96}
97
98#[derive(Debug, Clone, PartialEq)]
99pub struct ChartSpec {
100 pub chart_type: ChartType,
101 pub series: Vec<ChartSeries>,
102 pub title: Option<String>,
103 pub x_label: Option<String>,
104 pub y_label: Option<String>,
105 pub width: Option<usize>,
106 pub height: Option<usize>,
107 pub echarts_options: Option<serde_json::Value>,
109 pub interactive: bool,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq)]
114pub enum ChartType {
115 Line,
116 Bar,
117 Scatter,
118 Area,
119 Candlestick,
120 Histogram,
121}
122
123#[derive(Debug, Clone, PartialEq)]
124pub struct ChartSeries {
125 pub label: String,
126 pub data: Vec<(f64, f64)>,
127 pub color: Option<Color>,
128}
129
130impl ContentNode {
131 pub fn plain(text: impl Into<String>) -> Self {
133 ContentNode::Text(StyledText {
134 spans: vec![StyledSpan {
135 text: text.into(),
136 style: Style::default(),
137 }],
138 })
139 }
140
141 pub fn styled(text: impl Into<String>, style: Style) -> Self {
143 ContentNode::Text(StyledText {
144 spans: vec![StyledSpan {
145 text: text.into(),
146 style,
147 }],
148 })
149 }
150
151 pub fn with_fg(self, color: Color) -> Self {
153 match self {
154 ContentNode::Text(mut st) => {
155 for span in &mut st.spans {
156 span.style.fg = Some(color.clone());
157 }
158 ContentNode::Text(st)
159 }
160 other => other,
161 }
162 }
163
164 pub fn with_bg(self, color: Color) -> Self {
166 match self {
167 ContentNode::Text(mut st) => {
168 for span in &mut st.spans {
169 span.style.bg = Some(color.clone());
170 }
171 ContentNode::Text(st)
172 }
173 other => other,
174 }
175 }
176
177 pub fn with_bold(self) -> Self {
179 match self {
180 ContentNode::Text(mut st) => {
181 for span in &mut st.spans {
182 span.style.bold = true;
183 }
184 ContentNode::Text(st)
185 }
186 other => other,
187 }
188 }
189
190 pub fn with_italic(self) -> Self {
192 match self {
193 ContentNode::Text(mut st) => {
194 for span in &mut st.spans {
195 span.style.italic = true;
196 }
197 ContentNode::Text(st)
198 }
199 other => other,
200 }
201 }
202
203 pub fn with_underline(self) -> Self {
205 match self {
206 ContentNode::Text(mut st) => {
207 for span in &mut st.spans {
208 span.style.underline = true;
209 }
210 ContentNode::Text(st)
211 }
212 other => other,
213 }
214 }
215
216 pub fn with_dim(self) -> Self {
218 match self {
219 ContentNode::Text(mut st) => {
220 for span in &mut st.spans {
221 span.style.dim = true;
222 }
223 ContentNode::Text(st)
224 }
225 other => other,
226 }
227 }
228}
229
230impl fmt::Display for ContentNode {
231 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232 match self {
233 ContentNode::Text(st) => {
234 for span in &st.spans {
235 write!(f, "{}", span.text)?;
236 }
237 Ok(())
238 }
239 ContentNode::Table(table) => {
240 if !table.headers.is_empty() {
241 for (i, header) in table.headers.iter().enumerate() {
242 if i > 0 {
243 write!(f, " | ")?;
244 }
245 write!(f, "{}", header)?;
246 }
247 writeln!(f)?;
248 for (i, _) in table.headers.iter().enumerate() {
249 if i > 0 {
250 write!(f, "-+-")?;
251 }
252 write!(f, "---")?;
253 }
254 writeln!(f)?;
255 }
256 let limit = table.max_rows.unwrap_or(table.rows.len());
257 for row in table.rows.iter().take(limit) {
258 for (i, cell) in row.iter().enumerate() {
259 if i > 0 {
260 write!(f, " | ")?;
261 }
262 write!(f, "{}", cell)?;
263 }
264 writeln!(f)?;
265 }
266 Ok(())
267 }
268 ContentNode::Code { source, .. } => write!(f, "{}", source),
269 ContentNode::Chart(spec) => {
270 write!(
271 f,
272 "[Chart: {}]",
273 spec.title.as_deref().unwrap_or("untitled")
274 )
275 }
276 ContentNode::KeyValue(pairs) => {
277 for (i, (key, value)) in pairs.iter().enumerate() {
278 if i > 0 {
279 writeln!(f)?;
280 }
281 write!(f, "{}: {}", key, value)?;
282 }
283 Ok(())
284 }
285 ContentNode::Fragment(parts) => {
286 for part in parts {
287 write!(f, "{}", part)?;
288 }
289 Ok(())
290 }
291 }
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn test_plain_text_node() {
301 let node = ContentNode::plain("hello world");
302 match &node {
303 ContentNode::Text(st) => {
304 assert_eq!(st.spans.len(), 1);
305 assert_eq!(st.spans[0].text, "hello world");
306 assert_eq!(st.spans[0].style, Style::default());
307 }
308 _ => panic!("expected Text variant"),
309 }
310 }
311
312 #[test]
313 fn test_styled_text_node() {
314 let style = Style {
315 bold: true,
316 fg: Some(Color::Named(NamedColor::Red)),
317 ..Default::default()
318 };
319 let node = ContentNode::styled("warning", style.clone());
320 match &node {
321 ContentNode::Text(st) => {
322 assert_eq!(st.spans.len(), 1);
323 assert_eq!(st.spans[0].text, "warning");
324 assert_eq!(st.spans[0].style, style);
325 }
326 _ => panic!("expected Text variant"),
327 }
328 }
329
330 #[test]
331 fn test_content_node_display() {
332 assert_eq!(ContentNode::plain("hello").to_string(), "hello");
333
334 let code = ContentNode::Code {
335 language: Some("rust".into()),
336 source: "fn main() {}".into(),
337 };
338 assert_eq!(code.to_string(), "fn main() {}");
339
340 let chart = ContentNode::Chart(ChartSpec {
341 chart_type: ChartType::Line,
342 series: vec![],
343 title: Some("My Chart".into()),
344 x_label: None,
345 y_label: None,
346 width: None,
347 height: None,
348 echarts_options: None,
349 interactive: true,
350 });
351 assert_eq!(chart.to_string(), "[Chart: My Chart]");
352
353 let chart_no_title = ContentNode::Chart(ChartSpec {
354 chart_type: ChartType::Bar,
355 series: vec![],
356 title: None,
357 x_label: None,
358 y_label: None,
359 width: None,
360 height: None,
361 echarts_options: None,
362 interactive: true,
363 });
364 assert_eq!(chart_no_title.to_string(), "[Chart: untitled]");
365 }
366
367 #[test]
368 fn test_with_fg_color() {
369 let node = ContentNode::plain("text").with_fg(Color::Named(NamedColor::Green));
370 match &node {
371 ContentNode::Text(st) => {
372 assert_eq!(st.spans[0].style.fg, Some(Color::Named(NamedColor::Green)));
373 }
374 _ => panic!("expected Text variant"),
375 }
376 }
377
378 #[test]
379 fn test_with_bold() {
380 let node = ContentNode::plain("text").with_bold();
381 match &node {
382 ContentNode::Text(st) => {
383 assert!(st.spans[0].style.bold);
384 }
385 _ => panic!("expected Text variant"),
386 }
387 }
388
389 #[test]
390 fn test_with_italic() {
391 let node = ContentNode::plain("text").with_italic();
392 match &node {
393 ContentNode::Text(st) => {
394 assert!(st.spans[0].style.italic);
395 }
396 _ => panic!("expected Text variant"),
397 }
398 }
399
400 #[test]
401 fn test_with_underline() {
402 let node = ContentNode::plain("text").with_underline();
403 match &node {
404 ContentNode::Text(st) => {
405 assert!(st.spans[0].style.underline);
406 }
407 _ => panic!("expected Text variant"),
408 }
409 }
410
411 #[test]
412 fn test_with_dim() {
413 let node = ContentNode::plain("text").with_dim();
414 match &node {
415 ContentNode::Text(st) => {
416 assert!(st.spans[0].style.dim);
417 }
418 _ => panic!("expected Text variant"),
419 }
420 }
421
422 #[test]
423 fn test_with_bg_color() {
424 let node = ContentNode::plain("text").with_bg(Color::Rgb(255, 0, 0));
425 match &node {
426 ContentNode::Text(st) => {
427 assert_eq!(st.spans[0].style.bg, Some(Color::Rgb(255, 0, 0)));
428 }
429 _ => panic!("expected Text variant"),
430 }
431 }
432
433 #[test]
434 fn test_style_chaining() {
435 let node = ContentNode::plain("text")
436 .with_bold()
437 .with_fg(Color::Named(NamedColor::Cyan))
438 .with_underline();
439 match &node {
440 ContentNode::Text(st) => {
441 assert!(st.spans[0].style.bold);
442 assert!(st.spans[0].style.underline);
443 assert_eq!(st.spans[0].style.fg, Some(Color::Named(NamedColor::Cyan)));
444 }
445 _ => panic!("expected Text variant"),
446 }
447 }
448
449 #[test]
450 fn test_non_text_node_style_passthrough() {
451 let code = ContentNode::Code {
452 language: None,
453 source: "x = 1".into(),
454 };
455 let result = code.with_bold();
456 match &result {
457 ContentNode::Code { source, .. } => assert_eq!(source, "x = 1"),
458 _ => panic!("expected Code variant"),
459 }
460 }
461
462 #[test]
463 fn test_fragment_composition() {
464 let frag = ContentNode::Fragment(vec![
465 ContentNode::plain("hello "),
466 ContentNode::plain("world"),
467 ]);
468 assert_eq!(frag.to_string(), "hello world");
469 }
470
471 #[test]
472 fn test_key_value_display() {
473 let kv = ContentNode::KeyValue(vec![
474 ("name".into(), ContentNode::plain("Alice")),
475 ("age".into(), ContentNode::plain("30")),
476 ]);
477 assert_eq!(kv.to_string(), "name: Alice\nage: 30");
478 }
479
480 #[test]
481 fn test_table_display() {
482 let table = ContentNode::Table(ContentTable {
483 headers: vec!["Name".into(), "Value".into()],
484 rows: vec![
485 vec![ContentNode::plain("a"), ContentNode::plain("1")],
486 vec![ContentNode::plain("b"), ContentNode::plain("2")],
487 ],
488 border: BorderStyle::default(),
489 max_rows: None,
490 column_types: None,
491 total_rows: None,
492 sortable: false,
493 });
494 let output = table.to_string();
495 assert!(output.contains("Name"));
496 assert!(output.contains("Value"));
497 assert!(output.contains("a"));
498 assert!(output.contains("1"));
499 assert!(output.contains("b"));
500 assert!(output.contains("2"));
501 }
502
503 #[test]
504 fn test_table_max_rows() {
505 let table = ContentNode::Table(ContentTable {
506 headers: vec!["X".into()],
507 rows: vec![
508 vec![ContentNode::plain("1")],
509 vec![ContentNode::plain("2")],
510 vec![ContentNode::plain("3")],
511 ],
512 border: BorderStyle::None,
513 max_rows: Some(2),
514 column_types: None,
515 total_rows: None,
516 sortable: false,
517 });
518 let output = table.to_string();
519 assert!(output.contains("1"));
520 assert!(output.contains("2"));
521 assert!(!output.contains("3"));
522 }
523
524 #[test]
525 fn test_content_node_equality() {
526 let a = ContentNode::plain("hello");
527 let b = ContentNode::plain("hello");
528 let c = ContentNode::plain("world");
529 assert_eq!(a, b);
530 assert_ne!(a, c);
531 }
532
533 #[test]
534 fn test_border_style_default() {
535 assert_eq!(BorderStyle::default(), BorderStyle::Rounded);
536 }
537}