Skip to main content

shape_runtime/
content_dispatch.rs

1//! Content trait dispatch — render any ValueWord value as a ContentNode.
2//!
3//! The `render_as_content` function implements the Content trait dispatch logic:
4//! 1. If the value IS already a ContentNode → return as-is
5//! 2. Match by NanTag/HeapValue type → built-in Content impls for primitives
6//! 3. Fallback → Display as ContentNode::plain(display_string)
7//!
8//! Built-in Content implementations:
9//! - string → ContentNode::plain(self)
10//! - number/int/decimal → ContentNode::plain(formatted)
11//! - bool → ContentNode::plain("true"/"false")
12//! - Vec<TypedObject> → ContentNode::Table with columns from schema fields
13//! - Vec<scalar> → ContentNode::plain("[1, 2, 3]")
14//! - HashMap<K,V> → ContentNode::KeyValue
15//! - TypedObject → ContentNode::KeyValue with field names from schema
16//!
17//! ## ContentFor<Adapter>
18//!
19//! The `render_as_content_for` function adds adapter-aware dispatch:
20//! 1. ContentFor<CurrentAdapter> → adapter-specific rendering
21//! 2. Content → generic content rendering
22//! 3. Display fallback → plain text
23//!
24//! Adapter types: Terminal, Html, Markdown, Json, Plain
25
26use crate::content_renderer::RendererCapabilities;
27use crate::type_schema::{SchemaId, lookup_schema_by_id_public};
28use shape_value::content::{BorderStyle, ContentNode, ContentTable};
29use shape_value::heap_value::HeapValue;
30use shape_value::value_word::NanTag;
31use shape_value::{DataTable, ValueWord};
32
33/// Well-known adapter names for ContentFor<Adapter> dispatch.
34pub mod adapters {
35    pub const TERMINAL: &str = "Terminal";
36    pub const HTML: &str = "Html";
37    pub const MARKDOWN: &str = "Markdown";
38    pub const JSON: &str = "Json";
39    pub const PLAIN: &str = "Plain";
40}
41
42/// Render a ValueWord value as a ContentNode using built-in Content dispatch.
43///
44/// Dispatch order:
45/// 1. If value IS a ContentNode → return as-is
46/// 2. If value type has a built-in Content impl → produce structured output
47/// 3. Else → Display fallback → ContentNode::plain(display_string)
48pub fn render_as_content(value: &ValueWord) -> ContentNode {
49    // Fast path: already a content node
50    if let Some(node) = value.as_content() {
51        return node.clone();
52    }
53
54    match value.tag() {
55        NanTag::I48 => ContentNode::plain(format!("{}", value)),
56        NanTag::F64 => ContentNode::plain(format!("{}", value)),
57        NanTag::Bool => ContentNode::plain(format!("{}", value)),
58        NanTag::None => ContentNode::plain("none".to_string()),
59        NanTag::Unit => ContentNode::plain("()".to_string()),
60        NanTag::Heap => render_heap_as_content(value),
61        _ => ContentNode::plain(format!("{}", value)),
62    }
63}
64
65/// Render a ValueWord value as a ContentNode with adapter-specific dispatch.
66///
67/// Dispatch order:
68/// 1. ContentFor<adapter> → adapter-specific rendering (future: user-defined impls)
69/// 2. Content → generic content rendering via `render_as_content`
70/// 3. Display fallback → plain text
71///
72/// The `caps` parameter provides renderer capabilities so the Content impl
73/// can adapt output (e.g., use ANSI codes only when `caps.ansi` is true).
74pub fn render_as_content_for(
75    value: &ValueWord,
76    _adapter: &str,
77    _caps: &RendererCapabilities,
78) -> ContentNode {
79    // Currently all adapter-specific dispatch falls through to generic Content.
80    // When user-defined ContentFor<Adapter> impls are supported at the VM level,
81    // this function will check for a registered ContentFor impl first.
82    render_as_content(value)
83}
84
85/// Create a RendererCapabilities descriptor for a given adapter name.
86pub fn capabilities_for_adapter(adapter: &str) -> RendererCapabilities {
87    match adapter {
88        adapters::TERMINAL => RendererCapabilities::terminal(),
89        adapters::HTML => RendererCapabilities::html(),
90        adapters::MARKDOWN => RendererCapabilities::markdown(),
91        adapters::PLAIN => RendererCapabilities::plain(),
92        adapters::JSON => RendererCapabilities {
93            ansi: false,
94            unicode: true,
95            color: false,
96            interactive: false,
97        },
98        _ => RendererCapabilities::plain(),
99    }
100}
101
102/// Dispatch Content rendering for heap-allocated values.
103fn render_heap_as_content(value: &ValueWord) -> ContentNode {
104    match value.as_heap_ref() {
105        Some(HeapValue::String(s)) => ContentNode::plain(s.as_ref().clone()),
106        Some(HeapValue::Decimal(d)) => ContentNode::plain(d.to_string()),
107        Some(HeapValue::BigInt(i)) => ContentNode::plain(i.to_string()),
108        Some(HeapValue::Array(arr)) => render_array_as_content(arr),
109        Some(HeapValue::HashMap(d)) => render_hashmap_as_content(&d.keys, &d.values),
110        Some(HeapValue::TypedObject {
111            schema_id,
112            slots,
113            heap_mask,
114        }) => render_typed_object_as_content(*schema_id, slots, *heap_mask),
115        Some(HeapValue::DataTable(dt)) => datatable_to_content_node(dt, None),
116        Some(HeapValue::TypedTable { table, .. }) => datatable_to_content_node(table, None),
117        Some(HeapValue::IndexedTable { table, .. }) => datatable_to_content_node(table, None),
118        _ => ContentNode::plain(format!("{}", value)),
119    }
120}
121
122/// Render a single TypedObject as ContentNode::KeyValue using schema field names.
123fn render_typed_object_as_content(
124    schema_id: u64,
125    slots: &[shape_value::slot::ValueSlot],
126    heap_mask: u64,
127) -> ContentNode {
128    let sid = schema_id as SchemaId;
129    if let Some(schema) = lookup_schema_by_id_public(sid) {
130        let mut pairs = Vec::with_capacity(schema.fields.len());
131        for (i, field_def) in schema.fields.iter().enumerate() {
132            if i < slots.len() {
133                let val = extract_slot_value(&slots[i], heap_mask, i, &field_def.field_type);
134                let value_node = render_as_content(&val);
135                pairs.push((field_def.name.clone(), value_node));
136            }
137        }
138        ContentNode::KeyValue(pairs)
139    } else {
140        // Schema not found — fall back to Display
141        ContentNode::plain(format!("TypedObject(schema={})", schema_id))
142    }
143}
144
145/// Extract a ValueWord value from a ValueSlot using the schema field type.
146fn extract_slot_value(
147    slot: &shape_value::slot::ValueSlot,
148    heap_mask: u64,
149    index: usize,
150    field_type: &crate::type_schema::FieldType,
151) -> ValueWord {
152    use crate::type_schema::FieldType;
153    if heap_mask & (1u64 << index) != 0 {
154        slot.as_heap_nb()
155    } else {
156        match field_type {
157            FieldType::I64 => ValueWord::from_i64(slot.as_f64() as i64),
158            FieldType::Bool => ValueWord::from_bool(slot.as_bool()),
159            FieldType::Decimal => ValueWord::from_decimal(
160                rust_decimal::Decimal::from_f64_retain(slot.as_f64()).unwrap_or_default(),
161            ),
162            _ => ValueWord::from_f64(slot.as_f64()),
163        }
164    }
165}
166
167/// Render an array as a ContentNode.
168///
169/// For arrays of typed objects, renders as a table with columns from the schema.
170/// For scalar arrays, renders as "[1, 2, 3]".
171fn render_array_as_content(arr: &[ValueWord]) -> ContentNode {
172    if arr.is_empty() {
173        return ContentNode::plain("[]".to_string());
174    }
175
176    // Check first element to determine rendering strategy
177    if let Some(HeapValue::TypedObject { .. }) = arr.first().and_then(|v| v.as_heap_ref()) {
178        return render_typed_array_as_table(arr);
179    }
180
181    // Scalar array → "[1, 2, 3]"
182    let items: Vec<String> = arr.iter().map(|v| format!("{}", v)).collect();
183    ContentNode::plain(format!("[{}]", items.join(", ")))
184}
185
186/// Render an array of typed objects as a ContentNode::Table.
187///
188/// Extracts headers from the first element's schema and renders each row's
189/// field values as Content-dispatched cells. Falls back to single-column
190/// display if the schema cannot be resolved.
191fn render_typed_array_as_table(arr: &[ValueWord]) -> ContentNode {
192    // Get schema from first element
193    if let Some((schema_id, _, _)) = arr.first().and_then(|v| v.as_typed_object()) {
194        let sid = schema_id as SchemaId;
195        if let Some(schema) = lookup_schema_by_id_public(sid) {
196            let headers: Vec<String> = schema.fields.iter().map(|f| f.name.clone()).collect();
197
198            let mut rows: Vec<Vec<ContentNode>> = Vec::with_capacity(arr.len());
199            for elem in arr {
200                if let Some((_eid, slots, heap_mask)) = elem.as_typed_object() {
201                    let mut row_cells: Vec<ContentNode> = Vec::with_capacity(schema.fields.len());
202                    for (i, field_def) in schema.fields.iter().enumerate() {
203                        if i < slots.len() {
204                            let val =
205                                extract_slot_value(&slots[i], heap_mask, i, &field_def.field_type);
206                            row_cells.push(render_as_content(&val));
207                        } else {
208                            row_cells.push(ContentNode::plain("".to_string()));
209                        }
210                    }
211                    rows.push(row_cells);
212                } else {
213                    // Non-TypedObject element in the array — single cell fallback
214                    let mut cells = vec![ContentNode::plain(format!("{}", elem))];
215                    cells.resize(headers.len(), ContentNode::plain("".to_string()));
216                    rows.push(cells);
217                }
218            }
219
220            return ContentNode::Table(ContentTable {
221                headers,
222                rows,
223                border: BorderStyle::default(),
224                max_rows: None,
225                column_types: None,
226                total_rows: None,
227                sortable: false,
228            });
229        }
230    }
231
232    // Fallback: single-column display
233    let mut rows: Vec<Vec<ContentNode>> = Vec::with_capacity(arr.len());
234    for elem in arr {
235        rows.push(vec![ContentNode::plain(format!("{}", elem))]);
236    }
237
238    ContentNode::Table(ContentTable {
239        headers: vec!["value".to_string()],
240        rows,
241        border: BorderStyle::default(),
242        max_rows: None,
243        column_types: None,
244        total_rows: None,
245        sortable: false,
246    })
247}
248
249/// Convert a DataTable (Arrow RecordBatch wrapper) to a ContentNode::Table.
250///
251/// Extracts column names as headers, determines column types from the Arrow schema,
252/// and converts each cell to a plain text ContentNode. Optionally limits the number
253/// of rows displayed.
254pub fn datatable_to_content_node(dt: &DataTable, max_rows: Option<usize>) -> ContentNode {
255    use arrow_array::Array;
256
257    let headers = dt.column_names();
258    let total = dt.row_count();
259    let limit = max_rows.unwrap_or(total).min(total);
260
261    // Determine column types from Arrow schema
262    let schema = dt.inner().schema();
263    let column_types: Vec<String> = schema
264        .fields()
265        .iter()
266        .map(|f| arrow_type_label(f.data_type()))
267        .collect();
268
269    // Build rows
270    let batch = dt.inner();
271    let mut rows = Vec::with_capacity(limit);
272    for row_idx in 0..limit {
273        let mut cells = Vec::with_capacity(headers.len());
274        for col_idx in 0..headers.len() {
275            let col = batch.column(col_idx);
276            let text = if col.is_null(row_idx) {
277                "null".to_string()
278            } else {
279                arrow_cell_display(col.as_ref(), row_idx)
280            };
281            cells.push(ContentNode::plain(text));
282        }
283        rows.push(cells);
284    }
285
286    ContentNode::Table(ContentTable {
287        headers,
288        rows,
289        border: BorderStyle::default(),
290        max_rows: None, // already truncated above
291        column_types: Some(column_types),
292        total_rows: if total > limit { Some(total) } else { None },
293        sortable: true,
294    })
295}
296
297/// Map Arrow DataType to a human-readable type label.
298fn arrow_type_label(dt: &arrow_schema::DataType) -> String {
299    use arrow_schema::DataType;
300    match dt {
301        DataType::Float16 | DataType::Float32 | DataType::Float64 => "number".to_string(),
302        DataType::Int8 | DataType::Int16 | DataType::Int32 | DataType::Int64 => {
303            "number".to_string()
304        }
305        DataType::UInt8 | DataType::UInt16 | DataType::UInt32 | DataType::UInt64 => {
306            "number".to_string()
307        }
308        DataType::Boolean => "boolean".to_string(),
309        DataType::Utf8 | DataType::LargeUtf8 => "string".to_string(),
310        DataType::Date32 | DataType::Date64 => "date".to_string(),
311        DataType::Timestamp(_, _) => "date".to_string(),
312        DataType::Duration(_) => "duration".to_string(),
313        DataType::Decimal128(_, _) | DataType::Decimal256(_, _) => "number".to_string(),
314        _ => "string".to_string(),
315    }
316}
317
318/// Display a single Arrow cell value as a string.
319fn arrow_cell_display(array: &dyn arrow_array::Array, index: usize) -> String {
320    use arrow_array::cast::AsArray;
321    use arrow_array::types::*;
322    use arrow_schema::DataType;
323
324    match array.data_type() {
325        DataType::Float64 => format!("{}", array.as_primitive::<Float64Type>().value(index)),
326        DataType::Float32 => format!("{}", array.as_primitive::<Float32Type>().value(index)),
327        DataType::Int64 => format!("{}", array.as_primitive::<Int64Type>().value(index)),
328        DataType::Int32 => format!("{}", array.as_primitive::<Int32Type>().value(index)),
329        DataType::Int16 => format!("{}", array.as_primitive::<Int16Type>().value(index)),
330        DataType::Int8 => format!("{}", array.as_primitive::<Int8Type>().value(index)),
331        DataType::UInt64 => format!("{}", array.as_primitive::<UInt64Type>().value(index)),
332        DataType::UInt32 => format!("{}", array.as_primitive::<UInt32Type>().value(index)),
333        DataType::UInt16 => format!("{}", array.as_primitive::<UInt16Type>().value(index)),
334        DataType::UInt8 => format!("{}", array.as_primitive::<UInt8Type>().value(index)),
335        DataType::Boolean => format!("{}", array.as_boolean().value(index)),
336        DataType::Utf8 => array.as_string::<i32>().value(index).to_string(),
337        DataType::LargeUtf8 => array.as_string::<i64>().value(index).to_string(),
338        DataType::Timestamp(arrow_schema::TimeUnit::Microsecond, _) => {
339            let ts = array
340                .as_primitive::<TimestampMicrosecondType>()
341                .value(index);
342            match chrono::DateTime::from_timestamp_micros(ts) {
343                Some(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(),
344                None => ts.to_string(),
345            }
346        }
347        DataType::Timestamp(arrow_schema::TimeUnit::Millisecond, _) => {
348            let ts = array
349                .as_primitive::<TimestampMillisecondType>()
350                .value(index);
351            match chrono::DateTime::from_timestamp_millis(ts) {
352                Some(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(),
353                None => ts.to_string(),
354            }
355        }
356        _ => format!("{}", index),
357    }
358}
359
360/// Render a HashMap as ContentNode::KeyValue pairs.
361fn render_hashmap_as_content(keys: &[ValueWord], values: &[ValueWord]) -> ContentNode {
362    let mut pairs = Vec::with_capacity(keys.len());
363    for (k, v) in keys.iter().zip(values.iter()) {
364        let key_str = if let Some(s) = k.as_str() {
365            s.to_string()
366        } else {
367            format!("{}", k)
368        };
369        let value_node = render_as_content(v);
370        pairs.push((key_str, value_node));
371    }
372    ContentNode::KeyValue(pairs)
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use shape_value::content::ContentNode;
379    use std::sync::Arc;
380
381    #[test]
382    fn test_render_string_as_plain_text() {
383        let val = ValueWord::from_string(Arc::new("hello".to_string()));
384        let node = render_as_content(&val);
385        assert_eq!(node, ContentNode::plain("hello"));
386    }
387
388    #[test]
389    fn test_render_integer_as_plain_text() {
390        let val = ValueWord::from_i64(42);
391        let node = render_as_content(&val);
392        assert_eq!(node, ContentNode::plain("42"));
393    }
394
395    #[test]
396    fn test_render_float_as_plain_text() {
397        let val = ValueWord::from_f64(3.14);
398        let node = render_as_content(&val);
399        let text = node.to_string();
400        assert!(text.contains("3.14"), "expected 3.14, got: {}", text);
401    }
402
403    #[test]
404    fn test_render_bool_true() {
405        let val = ValueWord::from_bool(true);
406        let node = render_as_content(&val);
407        assert_eq!(node, ContentNode::plain("true"));
408    }
409
410    #[test]
411    fn test_render_bool_false() {
412        let val = ValueWord::from_bool(false);
413        let node = render_as_content(&val);
414        assert_eq!(node, ContentNode::plain("false"));
415    }
416
417    #[test]
418    fn test_render_none() {
419        let val = ValueWord::none();
420        let node = render_as_content(&val);
421        assert_eq!(node, ContentNode::plain("none"));
422    }
423
424    #[test]
425    fn test_render_content_node_passthrough() {
426        let original = ContentNode::plain("already content");
427        let val = ValueWord::from_content(original.clone());
428        let node = render_as_content(&val);
429        assert_eq!(node, original);
430    }
431
432    #[test]
433    fn test_render_scalar_array() {
434        let arr = Arc::new(vec![
435            ValueWord::from_i64(1),
436            ValueWord::from_i64(2),
437            ValueWord::from_i64(3),
438        ]);
439        let val = ValueWord::from_array(arr);
440        let node = render_as_content(&val);
441        assert_eq!(node, ContentNode::plain("[1, 2, 3]"));
442    }
443
444    #[test]
445    fn test_render_empty_array() {
446        let arr = Arc::new(vec![]);
447        let val = ValueWord::from_array(arr);
448        let node = render_as_content(&val);
449        assert_eq!(node, ContentNode::plain("[]"));
450    }
451
452    #[test]
453    fn test_render_hashmap_as_key_value() {
454        let keys = vec![ValueWord::from_string(Arc::new("name".to_string()))];
455        let values = vec![ValueWord::from_string(Arc::new("Alice".to_string()))];
456        let val = ValueWord::from_hashmap_pairs(keys, values);
457        let node = render_as_content(&val);
458        match &node {
459            ContentNode::KeyValue(pairs) => {
460                assert_eq!(pairs.len(), 1);
461                assert_eq!(pairs[0].0, "name");
462                assert_eq!(pairs[0].1, ContentNode::plain("Alice"));
463            }
464            _ => panic!("expected KeyValue, got: {:?}", node),
465        }
466    }
467
468    #[test]
469    fn test_render_decimal_as_plain_text() {
470        use rust_decimal::Decimal;
471        let val = ValueWord::from_decimal(Decimal::new(1234, 2)); // 12.34
472        let node = render_as_content(&val);
473        assert_eq!(node, ContentNode::plain("12.34"));
474    }
475
476    #[test]
477    fn test_render_unit() {
478        let val = ValueWord::unit();
479        let node = render_as_content(&val);
480        assert_eq!(node, ContentNode::plain("()"));
481    }
482
483    #[test]
484    fn test_typed_object_renders_as_key_value() {
485        use crate::type_schema::typed_object_from_pairs;
486
487        let obj = typed_object_from_pairs(&[
488            (
489                "name",
490                ValueWord::from_string(Arc::new("Alice".to_string())),
491            ),
492            ("age", ValueWord::from_i64(30)),
493        ]);
494        let node = render_as_content(&obj);
495        match &node {
496            ContentNode::KeyValue(pairs) => {
497                assert_eq!(pairs.len(), 2);
498                // Field names come from schema
499                let names: Vec<&str> = pairs.iter().map(|(k, _)| k.as_str()).collect();
500                assert!(
501                    names.contains(&"name"),
502                    "expected 'name' field, got: {:?}",
503                    names
504                );
505                assert!(
506                    names.contains(&"age"),
507                    "expected 'age' field, got: {:?}",
508                    names
509                );
510            }
511            _ => panic!("expected KeyValue for TypedObject, got: {:?}", node),
512        }
513    }
514
515    #[test]
516    fn test_typed_array_renders_as_table_with_headers() {
517        use crate::type_schema::typed_object_from_pairs;
518
519        let row1 = typed_object_from_pairs(&[
520            ("x", ValueWord::from_i64(1)),
521            ("y", ValueWord::from_i64(2)),
522        ]);
523        let row2 = typed_object_from_pairs(&[
524            ("x", ValueWord::from_i64(3)),
525            ("y", ValueWord::from_i64(4)),
526        ]);
527        let arr = Arc::new(vec![row1, row2]);
528        let val = ValueWord::from_array(arr);
529        let node = render_as_content(&val);
530        match &node {
531            ContentNode::Table(table) => {
532                assert_eq!(table.headers.len(), 2);
533                assert!(
534                    table.headers.contains(&"x".to_string()),
535                    "expected 'x' header"
536                );
537                assert!(
538                    table.headers.contains(&"y".to_string()),
539                    "expected 'y' header"
540                );
541                assert_eq!(table.rows.len(), 2);
542                // Each row should have 2 cells
543                assert_eq!(table.rows[0].len(), 2);
544                assert_eq!(table.rows[1].len(), 2);
545            }
546            _ => panic!("expected Table for Vec<TypedObject>, got: {:?}", node),
547        }
548    }
549
550    #[test]
551    fn test_adapter_capabilities() {
552        let terminal = capabilities_for_adapter(adapters::TERMINAL);
553        assert!(terminal.ansi);
554        assert!(terminal.color);
555        assert!(terminal.unicode);
556
557        let plain = capabilities_for_adapter(adapters::PLAIN);
558        assert!(!plain.ansi);
559        assert!(!plain.color);
560
561        let html = capabilities_for_adapter(adapters::HTML);
562        assert!(!html.ansi);
563        assert!(html.color);
564        assert!(html.interactive);
565
566        let json = capabilities_for_adapter(adapters::JSON);
567        assert!(!json.ansi);
568        assert!(!json.color);
569        assert!(json.unicode);
570    }
571
572    #[test]
573    fn test_render_as_content_for_falls_through() {
574        let val = ValueWord::from_i64(42);
575        let caps = capabilities_for_adapter(adapters::TERMINAL);
576        let node = render_as_content_for(&val, adapters::TERMINAL, &caps);
577        assert_eq!(node, ContentNode::plain("42"));
578    }
579
580    #[test]
581    fn test_datatable_to_content_node() {
582        use arrow_schema::{DataType, Field};
583        use shape_value::DataTableBuilder;
584
585        let mut builder = DataTableBuilder::with_fields(vec![
586            Field::new("name", DataType::Utf8, false),
587            Field::new("value", DataType::Float64, false),
588        ]);
589        builder.add_string_column(vec!["alpha", "beta", "gamma"]);
590        builder.add_f64_column(vec![1.0, 2.0, 3.0]);
591        let dt = builder.finish().expect("should build DataTable");
592
593        let node = datatable_to_content_node(&dt, None);
594        match &node {
595            ContentNode::Table(table) => {
596                assert_eq!(table.headers, vec!["name", "value"]);
597                assert_eq!(table.rows.len(), 3);
598                assert_eq!(table.rows[0][0], ContentNode::plain("alpha"));
599                assert_eq!(table.rows[0][1], ContentNode::plain("1"));
600                assert!(table.column_types.is_some());
601                let types = table.column_types.as_ref().unwrap();
602                assert_eq!(types[0], "string");
603                assert_eq!(types[1], "number");
604                assert!(table.sortable);
605            }
606            _ => panic!("expected Table, got: {:?}", node),
607        }
608    }
609
610    #[test]
611    fn test_datatable_to_content_node_with_max_rows() {
612        use arrow_schema::{DataType, Field};
613        use shape_value::DataTableBuilder;
614
615        let mut builder =
616            DataTableBuilder::with_fields(vec![Field::new("x", DataType::Int64, false)]);
617        builder.add_i64_column(vec![10, 20, 30, 40, 50]);
618        let dt = builder.finish().expect("should build DataTable");
619
620        let node = datatable_to_content_node(&dt, Some(2));
621        match &node {
622            ContentNode::Table(table) => {
623                assert_eq!(table.rows.len(), 2);
624                assert_eq!(table.total_rows, Some(5));
625            }
626            _ => panic!("expected Table, got: {:?}", node),
627        }
628    }
629
630    #[test]
631    fn test_datatable_renders_via_content_dispatch() {
632        use arrow_schema::{DataType, Field};
633        use shape_value::DataTableBuilder;
634
635        let mut builder =
636            DataTableBuilder::with_fields(vec![Field::new("col", DataType::Utf8, false)]);
637        builder.add_string_column(vec!["hello"]);
638        let dt = builder.finish().expect("should build DataTable");
639
640        let val = ValueWord::from_datatable(Arc::new(dt));
641        let node = render_as_content(&val);
642        match &node {
643            ContentNode::Table(table) => {
644                assert_eq!(table.headers, vec!["col"]);
645                assert_eq!(table.rows.len(), 1);
646            }
647            _ => panic!("expected Table for DataTable, got: {:?}", node),
648        }
649    }
650}