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