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        // Typed arrays: render as plain text with bracket notation
161        Some(HeapValue::IntArray(a)) => {
162            let elems: Vec<String> = a.iter().map(|v| v.to_string()).collect();
163            ContentNode::plain(format!("[{}]", elems.join(", ")))
164        }
165        Some(HeapValue::FloatArray(a)) => {
166            let elems: Vec<String> = a
167                .iter()
168                .map(|v| {
169                    if *v == v.trunc() && v.abs() < 1e15 {
170                        format!("{}", *v as i64)
171                    } else {
172                        format!("{}", v)
173                    }
174                })
175                .collect();
176            ContentNode::plain(format!("[{}]", elems.join(", ")))
177        }
178        Some(HeapValue::FloatArraySlice {
179            parent,
180            offset,
181            len,
182        }) => {
183            let start = *offset as usize;
184            let end = start + *len as usize;
185            let elems: Vec<String> = parent.data[start..end]
186                .iter()
187                .map(|v| {
188                    if *v == v.trunc() && v.abs() < 1e15 {
189                        format!("{}", *v as i64)
190                    } else {
191                        format!("{}", v)
192                    }
193                })
194                .collect();
195            ContentNode::plain(format!("[{}]", elems.join(", ")))
196        }
197        Some(HeapValue::BoolArray(a)) => {
198            let elems: Vec<String> = a
199                .iter()
200                .map(|v| if *v != 0 { "true" } else { "false" }.to_string())
201                .collect();
202            ContentNode::plain(format!("[{}]", elems.join(", ")))
203        }
204        _ => ContentNode::plain(format!("{}", value)),
205    }
206}
207
208/// Render a single TypedObject as ContentNode::KeyValue using schema field names.
209fn render_typed_object_as_content(
210    schema_id: u64,
211    slots: &[shape_value::slot::ValueSlot],
212    heap_mask: u64,
213) -> ContentNode {
214    let sid = schema_id as SchemaId;
215    if let Some(schema) = lookup_schema_by_id_public(sid) {
216        let mut pairs = Vec::with_capacity(schema.fields.len());
217        for (i, field_def) in schema.fields.iter().enumerate() {
218            if i < slots.len() {
219                let val = extract_slot_value(&slots[i], heap_mask, i, &field_def.field_type);
220                let value_node = render_as_content(&val);
221                pairs.push((field_def.name.clone(), value_node));
222            }
223        }
224        ContentNode::KeyValue(pairs)
225    } else {
226        // Schema not found — fall back to Display
227        ContentNode::plain(format!("TypedObject(schema={})", schema_id))
228    }
229}
230
231/// Extract a ValueWord value from a ValueSlot using the schema field type.
232fn extract_slot_value(
233    slot: &shape_value::slot::ValueSlot,
234    heap_mask: u64,
235    index: usize,
236    field_type: &crate::type_schema::FieldType,
237) -> ValueWord {
238    use crate::type_schema::FieldType;
239    if heap_mask & (1u64 << index) != 0 {
240        slot.as_heap_nb()
241    } else {
242        match field_type {
243            FieldType::I64 => ValueWord::from_i64(slot.as_f64() as i64),
244            FieldType::Bool => ValueWord::from_bool(slot.as_bool()),
245            FieldType::Decimal => ValueWord::from_decimal(
246                rust_decimal::Decimal::from_f64_retain(slot.as_f64()).unwrap_or_default(),
247            ),
248            _ => ValueWord::from_f64(slot.as_f64()),
249        }
250    }
251}
252
253/// Render an array as a ContentNode.
254///
255/// For arrays of typed objects, renders as a table with columns from the schema.
256/// For scalar arrays, renders as "[1, 2, 3]".
257fn render_array_as_content(arr: &[ValueWord]) -> ContentNode {
258    if arr.is_empty() {
259        return ContentNode::plain("[]".to_string());
260    }
261
262    // Check first element to determine rendering strategy
263    if let Some(HeapValue::TypedObject { .. }) = arr.first().and_then(|v| v.as_heap_ref()) {
264        return render_typed_array_as_table(arr);
265    }
266
267    // Scalar array → "[1, 2, 3]"
268    let items: Vec<String> = arr.iter().map(|v| format!("{}", v)).collect();
269    ContentNode::plain(format!("[{}]", items.join(", ")))
270}
271
272/// Render an array of typed objects as a ContentNode::Table.
273///
274/// Extracts headers from the first element's schema and renders each row's
275/// field values as Content-dispatched cells. Falls back to single-column
276/// display if the schema cannot be resolved.
277fn render_typed_array_as_table(arr: &[ValueWord]) -> ContentNode {
278    // Get schema from first element
279    if let Some((schema_id, _, _)) = arr.first().and_then(|v| v.as_typed_object()) {
280        let sid = schema_id as SchemaId;
281        if let Some(schema) = lookup_schema_by_id_public(sid) {
282            let headers: Vec<String> = schema.fields.iter().map(|f| f.name.clone()).collect();
283
284            let mut rows: Vec<Vec<ContentNode>> = Vec::with_capacity(arr.len());
285            for elem in arr {
286                if let Some((_eid, slots, heap_mask)) = elem.as_typed_object() {
287                    let mut row_cells: Vec<ContentNode> = Vec::with_capacity(schema.fields.len());
288                    for (i, field_def) in schema.fields.iter().enumerate() {
289                        if i < slots.len() {
290                            let val =
291                                extract_slot_value(&slots[i], heap_mask, i, &field_def.field_type);
292                            row_cells.push(render_as_content(&val));
293                        } else {
294                            row_cells.push(ContentNode::plain("".to_string()));
295                        }
296                    }
297                    rows.push(row_cells);
298                } else {
299                    // Non-TypedObject element in the array — single cell fallback
300                    let mut cells = vec![ContentNode::plain(format!("{}", elem))];
301                    cells.resize(headers.len(), ContentNode::plain("".to_string()));
302                    rows.push(cells);
303                }
304            }
305
306            return ContentNode::Table(ContentTable {
307                headers,
308                rows,
309                border: BorderStyle::default(),
310                max_rows: None,
311                column_types: None,
312                total_rows: None,
313                sortable: false,
314            });
315        }
316    }
317
318    // Fallback: single-column display
319    let mut rows: Vec<Vec<ContentNode>> = Vec::with_capacity(arr.len());
320    for elem in arr {
321        rows.push(vec![ContentNode::plain(format!("{}", elem))]);
322    }
323
324    ContentNode::Table(ContentTable {
325        headers: vec!["value".to_string()],
326        rows,
327        border: BorderStyle::default(),
328        max_rows: None,
329        column_types: None,
330        total_rows: None,
331        sortable: false,
332    })
333}
334
335/// Convert a DataTable (Arrow RecordBatch wrapper) to a ContentNode::Table.
336///
337/// Extracts column names as headers, determines column types from the Arrow schema,
338/// and converts each cell to a plain text ContentNode. Optionally limits the number
339/// of rows displayed.
340pub fn datatable_to_content_node(dt: &DataTable, max_rows: Option<usize>) -> ContentNode {
341    use arrow_array::Array;
342
343    let headers = dt.column_names();
344    let total = dt.row_count();
345    let limit = max_rows.unwrap_or(total).min(total);
346
347    // Determine column types from Arrow schema
348    let schema = dt.inner().schema();
349    let column_types: Vec<String> = schema
350        .fields()
351        .iter()
352        .map(|f| arrow_type_label(f.data_type()))
353        .collect();
354
355    // Build rows
356    let batch = dt.inner();
357    let mut rows = Vec::with_capacity(limit);
358    for row_idx in 0..limit {
359        let mut cells = Vec::with_capacity(headers.len());
360        for col_idx in 0..headers.len() {
361            let col = batch.column(col_idx);
362            let text = if col.is_null(row_idx) {
363                "null".to_string()
364            } else {
365                arrow_cell_display(col.as_ref(), row_idx)
366            };
367            cells.push(ContentNode::plain(text));
368        }
369        rows.push(cells);
370    }
371
372    ContentNode::Table(ContentTable {
373        headers,
374        rows,
375        border: BorderStyle::default(),
376        max_rows: None, // already truncated above
377        column_types: Some(column_types),
378        total_rows: if total > limit { Some(total) } else { None },
379        sortable: true,
380    })
381}
382
383/// Map Arrow DataType to a human-readable type label.
384fn arrow_type_label(dt: &arrow_schema::DataType) -> String {
385    use arrow_schema::DataType;
386    match dt {
387        DataType::Float16 | DataType::Float32 | DataType::Float64 => "number".to_string(),
388        DataType::Int8 | DataType::Int16 | DataType::Int32 | DataType::Int64 => {
389            "number".to_string()
390        }
391        DataType::UInt8 | DataType::UInt16 | DataType::UInt32 | DataType::UInt64 => {
392            "number".to_string()
393        }
394        DataType::Boolean => "boolean".to_string(),
395        DataType::Utf8 | DataType::LargeUtf8 => "string".to_string(),
396        DataType::Date32 | DataType::Date64 => "date".to_string(),
397        DataType::Timestamp(_, _) => "date".to_string(),
398        DataType::Duration(_) => "duration".to_string(),
399        DataType::Decimal128(_, _) | DataType::Decimal256(_, _) => "number".to_string(),
400        _ => "string".to_string(),
401    }
402}
403
404/// Display a single Arrow cell value as a string.
405fn arrow_cell_display(array: &dyn arrow_array::Array, index: usize) -> String {
406    use arrow_array::cast::AsArray;
407    use arrow_array::types::*;
408    use arrow_schema::DataType;
409
410    match array.data_type() {
411        DataType::Float64 => format!("{}", array.as_primitive::<Float64Type>().value(index)),
412        DataType::Float32 => format!("{}", array.as_primitive::<Float32Type>().value(index)),
413        DataType::Int64 => format!("{}", array.as_primitive::<Int64Type>().value(index)),
414        DataType::Int32 => format!("{}", array.as_primitive::<Int32Type>().value(index)),
415        DataType::Int16 => format!("{}", array.as_primitive::<Int16Type>().value(index)),
416        DataType::Int8 => format!("{}", array.as_primitive::<Int8Type>().value(index)),
417        DataType::UInt64 => format!("{}", array.as_primitive::<UInt64Type>().value(index)),
418        DataType::UInt32 => format!("{}", array.as_primitive::<UInt32Type>().value(index)),
419        DataType::UInt16 => format!("{}", array.as_primitive::<UInt16Type>().value(index)),
420        DataType::UInt8 => format!("{}", array.as_primitive::<UInt8Type>().value(index)),
421        DataType::Boolean => format!("{}", array.as_boolean().value(index)),
422        DataType::Utf8 => array.as_string::<i32>().value(index).to_string(),
423        DataType::LargeUtf8 => array.as_string::<i64>().value(index).to_string(),
424        DataType::Timestamp(arrow_schema::TimeUnit::Microsecond, _) => {
425            let ts = array
426                .as_primitive::<TimestampMicrosecondType>()
427                .value(index);
428            match chrono::DateTime::from_timestamp_micros(ts) {
429                Some(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(),
430                None => ts.to_string(),
431            }
432        }
433        DataType::Timestamp(arrow_schema::TimeUnit::Millisecond, _) => {
434            let ts = array
435                .as_primitive::<TimestampMillisecondType>()
436                .value(index);
437            match chrono::DateTime::from_timestamp_millis(ts) {
438                Some(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(),
439                None => ts.to_string(),
440            }
441        }
442        _ => format!("{}", index),
443    }
444}
445
446/// Render a HashMap as ContentNode::KeyValue pairs.
447fn render_hashmap_as_content(keys: &[ValueWord], values: &[ValueWord]) -> ContentNode {
448    let mut pairs = Vec::with_capacity(keys.len());
449    for (k, v) in keys.iter().zip(values.iter()) {
450        let key_str = if let Some(s) = k.as_str() {
451            s.to_string()
452        } else {
453            format!("{}", k)
454        };
455        let value_node = render_as_content(v);
456        pairs.push((key_str, value_node));
457    }
458    ContentNode::KeyValue(pairs)
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use shape_value::content::ContentNode;
465    use std::sync::Arc;
466
467    #[test]
468    fn test_render_string_as_plain_text() {
469        let val = ValueWord::from_string(Arc::new("hello".to_string()));
470        let node = render_as_content(&val);
471        assert_eq!(node, ContentNode::plain("hello"));
472    }
473
474    #[test]
475    fn test_render_integer_as_plain_text() {
476        let val = ValueWord::from_i64(42);
477        let node = render_as_content(&val);
478        assert_eq!(node, ContentNode::plain("42"));
479    }
480
481    #[test]
482    fn test_render_float_as_plain_text() {
483        let val = ValueWord::from_f64(3.14);
484        let node = render_as_content(&val);
485        let text = node.to_string();
486        assert!(text.contains("3.14"), "expected 3.14, got: {}", text);
487    }
488
489    #[test]
490    fn test_render_bool_true() {
491        let val = ValueWord::from_bool(true);
492        let node = render_as_content(&val);
493        assert_eq!(node, ContentNode::plain("true"));
494    }
495
496    #[test]
497    fn test_render_bool_false() {
498        let val = ValueWord::from_bool(false);
499        let node = render_as_content(&val);
500        assert_eq!(node, ContentNode::plain("false"));
501    }
502
503    #[test]
504    fn test_render_none() {
505        let val = ValueWord::none();
506        let node = render_as_content(&val);
507        assert_eq!(node, ContentNode::plain("none"));
508    }
509
510    #[test]
511    fn test_render_content_node_passthrough() {
512        let original = ContentNode::plain("already content");
513        let val = ValueWord::from_content(original.clone());
514        let node = render_as_content(&val);
515        assert_eq!(node, original);
516    }
517
518    #[test]
519    fn test_render_scalar_array() {
520        let arr = Arc::new(vec![
521            ValueWord::from_i64(1),
522            ValueWord::from_i64(2),
523            ValueWord::from_i64(3),
524        ]);
525        let val = ValueWord::from_array(arr);
526        let node = render_as_content(&val);
527        assert_eq!(node, ContentNode::plain("[1, 2, 3]"));
528    }
529
530    #[test]
531    fn test_render_empty_array() {
532        let arr = Arc::new(vec![]);
533        let val = ValueWord::from_array(arr);
534        let node = render_as_content(&val);
535        assert_eq!(node, ContentNode::plain("[]"));
536    }
537
538    #[test]
539    fn test_render_hashmap_as_key_value() {
540        let keys = vec![ValueWord::from_string(Arc::new("name".to_string()))];
541        let values = vec![ValueWord::from_string(Arc::new("Alice".to_string()))];
542        let val = ValueWord::from_hashmap_pairs(keys, values);
543        let node = render_as_content(&val);
544        match &node {
545            ContentNode::KeyValue(pairs) => {
546                assert_eq!(pairs.len(), 1);
547                assert_eq!(pairs[0].0, "name");
548                assert_eq!(pairs[0].1, ContentNode::plain("Alice"));
549            }
550            _ => panic!("expected KeyValue, got: {:?}", node),
551        }
552    }
553
554    #[test]
555    fn test_render_decimal_as_plain_text() {
556        use rust_decimal::Decimal;
557        let val = ValueWord::from_decimal(Decimal::new(1234, 2)); // 12.34
558        let node = render_as_content(&val);
559        assert_eq!(node, ContentNode::plain("12.34"));
560    }
561
562    #[test]
563    fn test_render_unit() {
564        let val = ValueWord::unit();
565        let node = render_as_content(&val);
566        assert_eq!(node, ContentNode::plain("()"));
567    }
568
569    #[test]
570    fn test_typed_object_renders_as_key_value() {
571        use crate::type_schema::typed_object_from_pairs;
572
573        let obj = typed_object_from_pairs(&[
574            (
575                "name",
576                ValueWord::from_string(Arc::new("Alice".to_string())),
577            ),
578            ("age", ValueWord::from_i64(30)),
579        ]);
580        let node = render_as_content(&obj);
581        match &node {
582            ContentNode::KeyValue(pairs) => {
583                assert_eq!(pairs.len(), 2);
584                // Field names come from schema
585                let names: Vec<&str> = pairs.iter().map(|(k, _)| k.as_str()).collect();
586                assert!(
587                    names.contains(&"name"),
588                    "expected 'name' field, got: {:?}",
589                    names
590                );
591                assert!(
592                    names.contains(&"age"),
593                    "expected 'age' field, got: {:?}",
594                    names
595                );
596            }
597            _ => panic!("expected KeyValue for TypedObject, got: {:?}", node),
598        }
599    }
600
601    #[test]
602    fn test_typed_array_renders_as_table_with_headers() {
603        use crate::type_schema::typed_object_from_pairs;
604
605        let row1 = typed_object_from_pairs(&[
606            ("x", ValueWord::from_i64(1)),
607            ("y", ValueWord::from_i64(2)),
608        ]);
609        let row2 = typed_object_from_pairs(&[
610            ("x", ValueWord::from_i64(3)),
611            ("y", ValueWord::from_i64(4)),
612        ]);
613        let arr = Arc::new(vec![row1, row2]);
614        let val = ValueWord::from_array(arr);
615        let node = render_as_content(&val);
616        match &node {
617            ContentNode::Table(table) => {
618                assert_eq!(table.headers.len(), 2);
619                assert!(
620                    table.headers.contains(&"x".to_string()),
621                    "expected 'x' header"
622                );
623                assert!(
624                    table.headers.contains(&"y".to_string()),
625                    "expected 'y' header"
626                );
627                assert_eq!(table.rows.len(), 2);
628                // Each row should have 2 cells
629                assert_eq!(table.rows[0].len(), 2);
630                assert_eq!(table.rows[1].len(), 2);
631            }
632            _ => panic!("expected Table for Vec<TypedObject>, got: {:?}", node),
633        }
634    }
635
636    #[test]
637    fn test_adapter_capabilities() {
638        let terminal = capabilities_for_adapter(adapters::TERMINAL);
639        assert!(terminal.ansi);
640        assert!(terminal.color);
641        assert!(terminal.unicode);
642
643        let plain = capabilities_for_adapter(adapters::PLAIN);
644        assert!(!plain.ansi);
645        assert!(!plain.color);
646
647        let html = capabilities_for_adapter(adapters::HTML);
648        assert!(!html.ansi);
649        assert!(html.color);
650        assert!(html.interactive);
651
652        let json = capabilities_for_adapter(adapters::JSON);
653        assert!(!json.ansi);
654        assert!(!json.color);
655        assert!(json.unicode);
656    }
657
658    #[test]
659    fn test_render_as_content_for_falls_through() {
660        let val = ValueWord::from_i64(42);
661        let caps = capabilities_for_adapter(adapters::TERMINAL);
662        let node = render_as_content_for(&val, adapters::TERMINAL, &caps);
663        assert_eq!(node, ContentNode::plain("42"));
664    }
665
666    #[test]
667    fn test_datatable_to_content_node() {
668        use arrow_schema::{DataType, Field};
669        use shape_value::DataTableBuilder;
670
671        let mut builder = DataTableBuilder::with_fields(vec![
672            Field::new("name", DataType::Utf8, false),
673            Field::new("value", DataType::Float64, false),
674        ]);
675        builder.add_string_column(vec!["alpha", "beta", "gamma"]);
676        builder.add_f64_column(vec![1.0, 2.0, 3.0]);
677        let dt = builder.finish().expect("should build DataTable");
678
679        let node = datatable_to_content_node(&dt, None);
680        match &node {
681            ContentNode::Table(table) => {
682                assert_eq!(table.headers, vec!["name", "value"]);
683                assert_eq!(table.rows.len(), 3);
684                assert_eq!(table.rows[0][0], ContentNode::plain("alpha"));
685                assert_eq!(table.rows[0][1], ContentNode::plain("1"));
686                assert!(table.column_types.is_some());
687                let types = table.column_types.as_ref().unwrap();
688                assert_eq!(types[0], "string");
689                assert_eq!(types[1], "number");
690                assert!(table.sortable);
691            }
692            _ => panic!("expected Table, got: {:?}", node),
693        }
694    }
695
696    #[test]
697    fn test_datatable_to_content_node_with_max_rows() {
698        use arrow_schema::{DataType, Field};
699        use shape_value::DataTableBuilder;
700
701        let mut builder =
702            DataTableBuilder::with_fields(vec![Field::new("x", DataType::Int64, false)]);
703        builder.add_i64_column(vec![10, 20, 30, 40, 50]);
704        let dt = builder.finish().expect("should build DataTable");
705
706        let node = datatable_to_content_node(&dt, Some(2));
707        match &node {
708            ContentNode::Table(table) => {
709                assert_eq!(table.rows.len(), 2);
710                assert_eq!(table.total_rows, Some(5));
711            }
712            _ => panic!("expected Table, got: {:?}", node),
713        }
714    }
715
716    #[test]
717    fn test_datatable_renders_via_content_dispatch() {
718        use arrow_schema::{DataType, Field};
719        use shape_value::DataTableBuilder;
720
721        let mut builder =
722            DataTableBuilder::with_fields(vec![Field::new("col", DataType::Utf8, false)]);
723        builder.add_string_column(vec!["hello"]);
724        let dt = builder.finish().expect("should build DataTable");
725
726        let val = ValueWord::from_datatable(Arc::new(dt));
727        let node = render_as_content(&val);
728        match &node {
729            ContentNode::Table(table) => {
730                assert_eq!(table.headers, vec!["col"]);
731                assert_eq!(table.rows.len(), 1);
732            }
733            _ => panic!("expected Table for DataTable, got: {:?}", node),
734        }
735    }
736}