Skip to main content

adam_web_ui/
labels.rs

1//! Cell display/write metadata and diagnostic formatting for a live [`adam_rs::Sheet`].
2//!
3//! [`Labels`] associates display metadata (names, type-erased display and write closures)
4//! with stable [`CellId`] keys, driving [`crate::SheetInspector`]'s rendering.
5//! [`format_adam_error`] formats an [`adam_rs::Error`] as a rustc-style diagnostic when
6//! possible.
7
8use adam_lang::type_registry::TypeShape;
9use adam_rs::{CellId, Error, Sheet};
10pub use annotate_snippets::Renderer;
11use cel_parser::{FormatRustcStyle, SpanContext, SpanLabel};
12use indexmap::IndexMap;
13use std::any::TypeId;
14
15/// Type-erased write closure: parses a string and writes it to a cell.
16pub type WriteStrFn = Box<dyn Fn(&mut Sheet, &str) -> Result<(), Error>>;
17
18/// Display and write metadata for a single cell.
19pub struct CellMeta {
20    /// Human-readable cell name shown in the inspector.
21    pub label: String,
22    /// `true` if the cell holds a `bool`, so [`crate::SheetInspector`] can render it as a
23    /// checkbox instead of a text field.
24    pub is_bool: bool,
25    /// `true` if the cell holds one of the 14 numeric primitive types, so
26    /// [`crate::SheetInspector`] can render it with [`crate::spectrum::SpNumberfield`]
27    /// instead of a plain text field.
28    pub is_numeric: bool,
29    /// `true` if the cell holds one of the 12 integer primitive types (as opposed to
30    /// `f32`/`f64`), so [`crate::SheetInspector`] can set its numeric widget's `step` to `1` —
31    /// [`crate::spectrum::SpNumberfield`]'s, or, for a range-filtered cell,
32    /// [`crate::spectrum::SpSlider`]'s — since an integer cell has no fractional steps to land
33    /// on.
34    pub is_integer: bool,
35    /// Returns the current cell value as a display string.
36    pub display: Box<dyn Fn(&Sheet) -> String>,
37    /// Parses `s` and writes the result to the cell; returns `Err` on parse failure or type
38    /// mismatch. May also always return `Err` for a cell type with no write support yet (e.g.
39    /// compound values — see [`Labels::add_compound_cell`]).
40    pub write_str: WriteStrFn,
41    /// Live slider bounds, present only for a numeric cell whose filter is a
42    /// [`adam_rs::FilterKind::Range`] — recomputed from the filter's current argument values on
43    /// every call, so a range driven by other cells or relationships stays live. Cast to `f64`
44    /// for display, matching [`format_rounded`]'s existing all-numeric-types-as-`f64` convention.
45    #[allow(clippy::type_complexity)]
46    pub range: Option<Box<dyn Fn(&Sheet) -> (f64, f64)>>,
47}
48
49/// Associates human-readable labels and type-erased closures with stable sheet IDs.
50pub struct Labels {
51    /// Cells in insertion order (preserves display ordering).
52    pub cells: IndexMap<CellId, CellMeta>,
53}
54
55impl Labels {
56    /// Creates an empty label set.
57    pub fn new() -> Self {
58        Self {
59            cells: IndexMap::new(),
60        }
61    }
62
63    /// Registers display metadata for a cell of type `T`.
64    ///
65    /// - Precondition: `id` is a live cell in the sheet this `Labels` will be used with.
66    /// - Precondition: `T` matches the type registered with `Sheet::add_cell` for `id`.
67    pub fn add_cell<T>(&mut self, id: CellId, label: &str)
68    where
69        T: std::any::Any + std::fmt::Display + std::str::FromStr + 'static,
70        T::Err: std::fmt::Display,
71    {
72        self.cells.insert(
73            id,
74            CellMeta {
75                label: label.to_owned(),
76                is_bool: TypeId::of::<T>() == TypeId::of::<bool>(),
77                is_numeric: false,
78                is_integer: false,
79                display: Box::new(move |sheet| {
80                    sheet
81                        .read::<T>(id)
82                        .map(|v| format!("{}", v))
83                        .unwrap_or_else(|_| "?".to_owned())
84                }),
85                write_str: Box::new(move |sheet, s| {
86                    let value = s.parse::<T>().map_err(|e| Error::MethodFailed {
87                        error: anyhow::anyhow!("parse error: {}", e),
88                        sites: vec![],
89                    })?;
90                    sheet.write(id, value)
91                }),
92                range: None,
93            },
94        );
95    }
96
97    /// Registers display-only metadata for a canonical compound cell of any shape.
98    ///
99    /// `write_str` always returns `Err` — no compound editor exists yet (tracked as a
100    /// follow-up: see the "Support editing tuple-typed cells in `begin`" GitHub issue). The
101    /// field still participates fully in [`crate::SheetInspector`]'s existing
102    /// invalid/warning/disabled machinery, since that's entirely keyed on `CellId`, not on any
103    /// per-type behavior.
104    ///
105    /// Missing storage or formatting capability displays `?`; unsupported writes
106    /// always return an explicit error without mutating the sheet.
107    ///
108    /// - Complexity: Preparation O(1); display O(n + v + s) for n type nodes,
109    ///   v values and s output bytes, excluding leaf formatter costs.
110    ///
111    /// # Examples
112    /// ```
113    /// let sheet = adam_lang::AdamParser::new(adam_lang::TypeRegistry::new(), cel_parser::OpLookup::new())
114    ///     .parse_str("sheet s { cell xs: [i32]; }").unwrap();
115    /// let id = sheet.cell_names["xs"].0;
116    /// let mut labels = adam_web_ui::Labels::new();
117    /// labels.add_compound_cell(&sheet, id, "xs");
118    /// assert_eq!((labels.cells[&id].display)(&sheet), "[]");
119    /// ```
120    pub fn add_compound_cell(&mut self, sheet: &Sheet, id: CellId, label: &str) {
121        let formatter = adam_lang::prepare_compound_cell_formatter(sheet, id);
122        self.cells.insert(
123            id,
124            CellMeta {
125                label: label.to_owned(),
126                is_bool: false,
127                is_numeric: false,
128                is_integer: false,
129                display: Box::new(move |sheet| {
130                    formatter
131                        .as_ref()
132                        .ok()
133                        .and_then(|format| format(sheet).ok())
134                        .unwrap_or_else(|| "?".to_owned())
135                }),
136                write_str: Box::new(|_sheet, _s| {
137                    Err(Error::MethodFailed {
138                        error: anyhow::anyhow!("editing compound cells is not yet supported"),
139                        sites: vec![],
140                    })
141                }),
142                range: None,
143            },
144        );
145    }
146}
147
148impl Default for Labels {
149    /// Returns `Labels::new()`.
150    fn default() -> Self {
151        Self::new()
152    }
153}
154
155/// Formats a floating-point value for display, rounded to 2 decimal places with
156/// trailing zeros (and a bare trailing decimal point) trimmed.
157///
158/// Used in place of plain `Display` for `f32`/`f64` cells so inspector fields show `86.67`
159/// and `300` rather than `86.666666666667` and `300.0`. Not applied to other cell types:
160/// precision has no meaningful effect on integers, and it would truncate `String` values
161/// outright.
162///
163/// # Examples
164///
165/// ```text
166/// format_rounded(86.666666666667) == "86.67"
167/// format_rounded(300.0)            == "300"
168/// format_rounded(-0.001)           == "0"
169/// ```
170pub fn format_rounded(v: f64) -> String {
171    let s = format!("{v:.2}");
172    let s = s.trim_end_matches('0');
173    let s = s.trim_end_matches('.');
174    if s == "-0" {
175        "0".to_string()
176    } else {
177        s.to_string()
178    }
179}
180
181/// Converts a filter-recognized numeric primitive to `f64` for display — the same "every numeric
182/// type displays as `f64`" convention [`format_rounded`] already documents. Implemented for
183/// exactly the 14 primitives `TypeRegistry::range_entry` recognizes range support for; `i64`,
184/// `u64`, `i128`, `u128`, `usize`, and `isize` lose precision beyond 2^53, identical to
185/// `labels_from_cell_names`'s existing `try_float_ty!`-driven display path for those types.
186trait ToF64Display {
187    fn to_f64_display(&self) -> f64;
188}
189
190macro_rules! impl_to_f64_display {
191    ($($T:ty),*) => {
192        $(impl ToF64Display for $T {
193            fn to_f64_display(&self) -> f64 {
194                *self as f64
195            }
196        })*
197    };
198}
199impl_to_f64_display!(
200    i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64
201);
202
203/// Builds a [`Labels`] from an adam-lang-style declaration-ordered cell name map.
204///
205/// Matches each scalar cell's `TypeId` against the built-in primitive types
206/// `adam_lang::TypeRegistry::new()` registers. Tuple and array cells
207/// appear with a Debug-formatted, display-only entry via
208/// [`Labels::add_compound_cell`]. Cells whose `TypeId` is none of the built-in
209/// primitives are silently skipped, so they simply won't appear in the sidebar.
210/// Callable signatures are skipped because no callable Inspector exists.
211///
212/// - Complexity: O(n) in the number of cells.
213pub fn labels_from_cell_names(
214    sheet: &Sheet,
215    cell_names: &IndexMap<String, (CellId, TypeShape)>,
216) -> Labels {
217    let mut labels = Labels::new();
218    for (name, (id, shape)) in cell_names {
219        let id = *id;
220        let type_id = match shape {
221            TypeShape::Named(type_id) => *type_id,
222            TypeShape::Tuple(_) | TypeShape::Array(_) => {
223                labels.add_compound_cell(sheet, id, name);
224                continue;
225            }
226            TypeShape::Callable(_, _) => continue,
227        };
228        macro_rules! try_numeric_ty {
229            ($T:ty) => {
230                if type_id == TypeId::of::<$T>() {
231                    labels.add_cell::<$T>(id, name);
232                    mark_numeric::<$T>(&mut labels, sheet, id, true);
233                    continue;
234                }
235            };
236        }
237        try_numeric_ty!(i8);
238        try_numeric_ty!(i16);
239        try_numeric_ty!(i32);
240        try_numeric_ty!(i64);
241        try_numeric_ty!(i128);
242        try_numeric_ty!(isize);
243        try_numeric_ty!(u8);
244        try_numeric_ty!(u16);
245        try_numeric_ty!(u32);
246        try_numeric_ty!(u64);
247        try_numeric_ty!(u128);
248        try_numeric_ty!(usize);
249
250        macro_rules! try_numeric_float_ty {
251            ($T:ty) => {
252                if type_id == TypeId::of::<$T>() {
253                    labels.add_cell::<$T>(id, name);
254                    if let Some(meta) = labels.cells.get_mut(&id) {
255                        meta.display = Box::new(move |sheet| {
256                            sheet
257                                .read::<$T>(id)
258                                .map(|v| format_rounded(*v as f64))
259                                .unwrap_or_else(|_| "?".to_owned())
260                        });
261                    }
262                    mark_numeric::<$T>(&mut labels, sheet, id, false);
263                    continue;
264                }
265            };
266        }
267        try_numeric_float_ty!(f32);
268        try_numeric_float_ty!(f64);
269
270        macro_rules! try_ty {
271            ($T:ty) => {
272                if type_id == TypeId::of::<$T>() {
273                    labels.add_cell::<$T>(id, name);
274                    continue;
275                }
276            };
277        }
278        try_ty!(bool);
279        try_ty!(String);
280    }
281    labels
282}
283
284/// Marks `id`'s `CellMeta` as numeric (and, per `is_integer`, as an integer type) and, if
285/// `sheet.filter_kind(id)` is a range clamp, populates its live-range closure.
286fn mark_numeric<T: std::any::Any + Clone + ToF64Display>(
287    labels: &mut Labels,
288    sheet: &Sheet,
289    id: CellId,
290    is_integer: bool,
291) {
292    let Some(meta) = labels.cells.get_mut(&id) else {
293        return;
294    };
295    meta.is_numeric = true;
296    meta.is_integer = is_integer;
297    if matches!(
298        sheet.filter_kind(id),
299        Some(adam_rs::FilterKind::Range { .. })
300    ) {
301        meta.range = Some(Box::new(move |sheet: &Sheet| {
302            sheet
303                .filter_range::<T>(id)
304                .map(|(lo, hi)| (lo.to_f64_display(), hi.to_f64_display()))
305                .unwrap_or((0.0, 0.0))
306        }));
307    }
308}
309
310/// Formats an [`Error`] as a rustc-style diagnostic when possible.
311///
312/// `Error::MethodFailed` wraps an `anyhow::Error` raised by a compiled method
313/// body; when that error carries a `SpanContext` (attached automatically by
314/// cel-parser's `span-diagnostics` feature for built-in arithmetic ops) this
315/// renders a full caret diagnostic against `source` using `renderer` — pass
316/// [`Renderer::styled`] for a real terminal (ANSI colors) or [`Renderer::plain`]
317/// for a context that can't display them (a browser `<pre>` element, a log file) —
318/// with `file_name` (e.g. `"begin/examples/toy_example.adm2"`) shown in the
319/// diagnostic header. Otherwise, `e`'s sites are resolved against `parsed` via
320/// `adam_lang::ParsedSheet::locate_error`: zero resolved sites falls back to `e`'s
321/// `Display` message; exactly one renders a single-caret diagnostic; two or more render
322/// a multi-span backtrace (e.g. every relationship binding in a cycle), primary site
323/// first.
324///
325/// - Complexity: O(n) in the length of `source`, plus O(s) in the number of `e`'s sites —
326///   `ParsedSheet::locate_error` and, on the multi-span path, `cel_parser::format_multi_span`
327///   both re-scan `source` to render the annotated snippet.
328pub fn format_adam_error(
329    e: &Error,
330    parsed: &adam_lang::ParsedSheet,
331    source: &str,
332    file_name: &str,
333    renderer: &Renderer,
334) -> String {
335    // CEL-internal sub-expression span wins when present.
336    if let Error::MethodFailed { error, .. } = e
337        && error.downcast_ref::<SpanContext>().is_some()
338    {
339        return error.format_rustc_style(source, file_name, 1, renderer);
340    }
341    let located = parsed.locate_error(e);
342    match located.as_slice() {
343        [] => e.to_string(),
344        [(span, _label)] => SpanContext::new(*span).format_rustc_style(
345            &e.to_string(),
346            source,
347            file_name,
348            1,
349            renderer,
350        ),
351        many => {
352            let labels: Vec<SpanLabel> = many
353                .iter()
354                .map(|(span, label)| SpanLabel {
355                    span: *span,
356                    label: label.clone(),
357                })
358                .collect();
359            cel_parser::format_multi_span(&e.to_string(), &labels, source, file_name, 1, renderer)
360        }
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use adam_rs::Sheet as AdamSheet;
368
369    /// Parses `src` into a `ParsedSheet` via the standard adam-lang pipeline, for tests that
370    /// need a real (rather than hand-built) span table to resolve `Error` sites against.
371    fn parse(src: &str) -> adam_lang::ParsedSheet {
372        use adam_lang::{AdamParser, TypeRegistry};
373        use cel_parser::OpLookup;
374        AdamParser::new(TypeRegistry::new(), OpLookup::new())
375            .parse_str(src)
376            .unwrap()
377    }
378
379    #[test]
380    fn format_adam_error_invalid_id_falls_back_to_display() {
381        let parsed = parse("sheet s { cell a: i32 = 0; }");
382        let msg = format_adam_error(
383            &Error::InvalidId,
384            &parsed,
385            "sheet s { cell a: i32 = 0; }",
386            "test.adm2",
387            &Renderer::styled(),
388        );
389        assert_eq!(msg, "invalid cell or relationship id");
390    }
391
392    #[test]
393    fn format_adam_error_method_failed_renders_caret_diagnostic() {
394        use cel_parser::{SourceSpan, SpanContext};
395
396        let parsed = parse("sheet s { cell a: i32 = 0; }");
397        let source = "1i32 / 0i32";
398        let span = SourceSpan::new(1, 0, 1, 11);
399        let inner = anyhow::anyhow!("division by zero").context(SpanContext::new(span));
400        let err = Error::MethodFailed {
401            error: inner,
402            sites: vec![],
403        };
404
405        let msg = format_adam_error(&err, &parsed, source, "test.adm2", &Renderer::styled());
406
407        assert!(msg.contains("division by zero"), "{msg}");
408        assert!(msg.contains(source), "{msg}");
409    }
410
411    #[test]
412    fn format_adam_error_plain_renderer_has_no_ansi_escape_codes() {
413        use cel_parser::{SourceSpan, SpanContext};
414
415        let parsed = parse("sheet s { cell a: i32 = 0; }");
416        let source = "1i32 / 0i32";
417        let span = SourceSpan::new(1, 0, 1, 11);
418        let inner = anyhow::anyhow!("division by zero").context(SpanContext::new(span));
419        let err = Error::MethodFailed {
420            error: inner,
421            sites: vec![],
422        };
423
424        let msg = format_adam_error(&err, &parsed, source, "test.adm2", &Renderer::plain());
425
426        assert!(msg.contains("division by zero"), "{msg}");
427        assert!(
428            !msg.contains('\u{1b}'),
429            "expected no ANSI escapes, got: {msg}"
430        );
431    }
432
433    #[test]
434    fn format_adam_error_method_failed_falls_back_to_a_single_located_span_without_a_span_context()
435    {
436        use adam_rs::ErrorSite;
437
438        let source =
439            "sheet s {\n    cell a: i32;\n    relationship {\n        a := 1i32;\n    }\n}";
440        let parsed = parse(source);
441        let &(rel_id, idx) = parsed.method_spans.keys().next().expect("one relationship");
442
443        let err = Error::MethodFailed {
444            error: anyhow::anyhow!("boom"),
445            sites: vec![ErrorSite::Method(rel_id, idx)],
446        };
447
448        let msg = format_adam_error(&err, &parsed, source, "test.adm2", &Renderer::plain());
449
450        assert!(msg.contains("boom"), "{msg}");
451        assert!(msg.contains("a := 1i32;"), "{msg}");
452    }
453
454    #[test]
455    fn format_adam_error_cycle_renders_a_multi_span_backtrace() {
456        use adam_lang::{AdamParser, TypeRegistry};
457        use cel_parser::OpLookup;
458        let src = "sheet s { cell x: i32 = 0; cell y: i32 = 0; relationship { x := y + 1i32; } relationship { y := x + 1i32; } }";
459        let mut parser = AdamParser::new(TypeRegistry::new(), OpLookup::new());
460        let mut parsed = parser.parse_str(src).unwrap();
461        let err = parsed.propagate().unwrap_err();
462        let msg = format_adam_error(&err, &parsed, src, "t.adm2", &Renderer::plain());
463        assert!(msg.contains("cycle"), "{msg}");
464        // both relationship bindings referenced
465        assert!(msg.contains("x :=") && msg.contains("y :="), "{msg}");
466    }
467
468    #[test]
469    fn format_rounded_trims_trailing_zeros_and_point() {
470        assert_eq!(format_rounded(86.666666666667), "86.67");
471        assert_eq!(format_rounded(300.0), "300");
472        assert_eq!(format_rounded(2.5), "2.5");
473        assert_eq!(format_rounded(0.0), "0");
474    }
475
476    #[test]
477    fn format_rounded_negative_zero_has_no_minus_sign() {
478        assert_eq!(format_rounded(-0.0), "0");
479        assert_eq!(format_rounded(-0.001), "0");
480    }
481
482    #[test]
483    fn labels_from_cell_names_marks_float_cells_as_numeric_but_not_integer() {
484        let mut sheet = AdamSheet::new();
485        let a = sheet.add_cell(2.0_f64);
486
487        let mut cell_names = IndexMap::new();
488        cell_names.insert("a".to_string(), (a, TypeShape::Named(TypeId::of::<f64>())));
489
490        let labels = labels_from_cell_names(&sheet, &cell_names);
491
492        assert!(labels.cells[&a].is_numeric);
493        assert!(!labels.cells[&a].is_integer);
494    }
495
496    #[test]
497    fn labels_from_cell_names_rounds_float_display_to_two_decimals() {
498        let mut sheet = AdamSheet::new();
499        let a = sheet.add_cell(86.666666666667_f64);
500
501        let mut cell_names = IndexMap::new();
502        cell_names.insert("a".to_string(), (a, TypeShape::Named(TypeId::of::<f64>())));
503
504        let labels = labels_from_cell_names(&sheet, &cell_names);
505
506        assert_eq!((labels.cells[&a].display)(&sheet), "86.67");
507    }
508
509    #[test]
510    fn labels_from_cell_names_builds_entries_for_supported_types() {
511        let mut sheet = AdamSheet::new();
512        let a = sheet.add_cell(2.0_f64);
513        let b = sheet.add_cell(3_i32);
514        let c = sheet.add_cell(true);
515        let d = sheet.add_cell("hi".to_string());
516
517        let mut cell_names = IndexMap::new();
518        cell_names.insert("a".to_string(), (a, TypeShape::Named(TypeId::of::<f64>())));
519        cell_names.insert("b".to_string(), (b, TypeShape::Named(TypeId::of::<i32>())));
520        cell_names.insert("c".to_string(), (c, TypeShape::Named(TypeId::of::<bool>())));
521        cell_names.insert(
522            "d".to_string(),
523            (d, TypeShape::Named(TypeId::of::<String>())),
524        );
525
526        let labels = labels_from_cell_names(&sheet, &cell_names);
527
528        assert_eq!(labels.cells.len(), 4);
529        assert_eq!((labels.cells[&a].display)(&sheet), "2");
530        assert_eq!((labels.cells[&b].display)(&sheet), "3");
531        assert_eq!((labels.cells[&c].display)(&sheet), "true");
532        assert_eq!((labels.cells[&d].display)(&sheet), "hi");
533        assert!(!labels.cells[&a].is_bool);
534        assert!(!labels.cells[&b].is_bool);
535        assert!(labels.cells[&c].is_bool);
536        assert!(!labels.cells[&d].is_bool);
537    }
538
539    #[test]
540    fn labels_from_cell_names_includes_tuple_typed_cells() {
541        let sheet = tuple_sheet();
542        let pair = sheet.cell_names["pair"].0;
543
544        let mut cell_names = IndexMap::new();
545        cell_names.insert(
546            "pair".to_string(),
547            (
548                pair,
549                TypeShape::Tuple(vec![
550                    TypeShape::Named(TypeId::of::<i32>()),
551                    TypeShape::Named(TypeId::of::<f64>()),
552                ]),
553            ),
554        );
555
556        let labels = labels_from_cell_names(&sheet, &cell_names);
557
558        assert_eq!(labels.cells.len(), 1);
559        assert_eq!((labels.cells[&pair].display)(&sheet), "(3, 4.5)");
560        assert!(!labels.cells[&pair].is_bool);
561    }
562
563    /// Compound labels display nested tuples, arrays, empty arrays, and source unit.
564    #[test]
565    fn labels_display_compound_values_and_reject_edits() {
566        let mut sheet =
567            adam_lang::AdamParser::new(adam_lang::TypeRegistry::new(), cel_parser::OpLookup::new())
568                .parse_str(
569                    "sheet s {
570                        cell xs: [(i32, bool)] = [(3, true), (4, false)];
571                        cell empty: [i32];
572                        cell nested: ([(i32, bool)], [i32]) = ([(7, true)], []: [i32]);
573                        cell unit: ();
574                        cell scalar: i32 = 5;
575                    }",
576                )
577                .unwrap();
578        let labels = labels_from_cell_names(&sheet, &sheet.cell_names);
579        assert_eq!(labels.cells.len(), 5);
580        for (name, expected) in [
581            ("xs", "[(3, true), (4, false)]"),
582            ("empty", "[]"),
583            ("nested", "([(7, true)], [])"),
584            ("unit", "()"),
585        ] {
586            let meta = &labels.cells[&sheet.cell_names[name].0];
587            assert_eq!((meta.display)(&sheet), expected);
588            assert!(!meta.is_bool && !meta.is_numeric && !meta.is_integer);
589            let error = (meta.write_str)(&mut sheet, "[]").unwrap_err();
590            assert!(error.to_string().contains("not yet supported"));
591            assert_eq!((meta.display)(&sheet), expected);
592        }
593        let scalar = &labels.cells[&sheet.cell_names["scalar"].0];
594        assert_eq!((scalar.display)(&sheet), "5");
595        (scalar.write_str)(&mut sheet, "9").unwrap();
596        assert_eq!((scalar.display)(&sheet), "9");
597        let id = sheet.cell_names["empty"].0;
598        sheet
599            .write_compound::<cel_runtime::ArrayOf<cel_runtime::Leaf<i32>>>(
600                id,
601                cel_runtime::Array::from_vec(vec![1, 2]),
602            )
603            .unwrap();
604        assert_eq!((labels.cells[&id].display)(&sheet), "[1, 2]");
605    }
606
607    #[test]
608    fn labels_from_cell_names_preserves_declaration_order() {
609        let mut sheet = AdamSheet::new();
610        let z = sheet.add_cell(1_i32);
611        let a = sheet.add_cell(2_i32);
612
613        let mut cell_names = IndexMap::new();
614        cell_names.insert("z".to_string(), (z, TypeShape::Named(TypeId::of::<i32>())));
615        cell_names.insert("a".to_string(), (a, TypeShape::Named(TypeId::of::<i32>())));
616
617        let labels = labels_from_cell_names(&sheet, &cell_names);
618        let ids: Vec<_> = labels.cells.keys().copied().collect();
619        assert_eq!(ids, vec![z, a]);
620    }
621
622    #[test]
623    fn labels_from_cell_names_marks_numeric_cells_and_leaves_range_none_without_a_filter() {
624        let mut sheet = AdamSheet::new();
625        let a = sheet.add_cell(3_i32);
626        let b = sheet.add_cell(true);
627
628        let mut cell_names = IndexMap::new();
629        cell_names.insert("a".to_string(), (a, TypeShape::Named(TypeId::of::<i32>())));
630        cell_names.insert("b".to_string(), (b, TypeShape::Named(TypeId::of::<bool>())));
631
632        let labels = labels_from_cell_names(&sheet, &cell_names);
633
634        assert!(labels.cells[&a].is_numeric);
635        assert!(labels.cells[&a].is_integer);
636        assert!(labels.cells[&a].range.is_none());
637        assert!(!labels.cells[&b].is_numeric);
638    }
639
640    #[test]
641    fn labels_from_cell_names_populates_range_for_a_range_filtered_cell() {
642        use adam_rs::Filter;
643        use std::any::Any;
644
645        let mut sheet = AdamSheet::new();
646        let a = sheet.add_cell(50_i32);
647        let filter = Filter::range(
648            TypeId::of::<i32>(),
649            vec![],
650            vec![],
651            |value, _args| Ok(Box::new(*value.downcast_ref::<i32>().unwrap()) as Box<dyn Any>),
652            |_args| {
653                Some((
654                    Box::new(0i32) as Box<dyn Any>,
655                    Box::new(100i32) as Box<dyn Any>,
656                ))
657            },
658        );
659        sheet.add_filter(a, filter).unwrap();
660
661        let mut cell_names = IndexMap::new();
662        cell_names.insert("a".to_string(), (a, TypeShape::Named(TypeId::of::<i32>())));
663
664        let labels = labels_from_cell_names(&sheet, &cell_names);
665
666        let range_fn = labels.cells[&a].range.as_ref().expect("range populated");
667        assert_eq!(range_fn(&sheet), (0.0, 100.0));
668    }
669
670    fn sheet_with_one_cell() -> (AdamSheet, Labels) {
671        let mut sheet = AdamSheet::new();
672        let mut labels = Labels::new();
673        let a = sheet.add_cell(2.0_f64);
674        labels.add_cell::<f64>(a, "a");
675        (sheet, labels)
676    }
677
678    #[test]
679    fn display_closure_returns_value_string() {
680        let (sheet, labels) = sheet_with_one_cell();
681        let a_id = *labels.cells.keys().next().unwrap();
682        let display = &labels.cells[&a_id].display;
683        assert_eq!(display(&sheet), "2");
684    }
685
686    #[test]
687    fn write_str_closure_parses_and_writes() {
688        let (mut sheet, labels) = sheet_with_one_cell();
689        let a_id = *labels.cells.keys().next().unwrap();
690        assert!((labels.cells[&a_id].write_str)(&mut sheet, "5.0").is_ok());
691        let display = &labels.cells[&a_id].display;
692        assert_eq!(display(&sheet), "5");
693    }
694
695    #[test]
696    fn add_tuple_cell_display_returns_rust_debug_formatted_string() {
697        let sheet = tuple_sheet();
698        let cell_id = sheet.cell_names["pair"].0;
699        let mut labels = Labels::new();
700        labels.add_compound_cell(&sheet, cell_id, "pair");
701        let meta = labels.cells.get(&cell_id).unwrap();
702        assert_eq!((meta.display)(&sheet), "(3, 4.5)");
703    }
704
705    #[test]
706    fn add_tuple_cell_write_str_always_errs_without_mutating_the_sheet() {
707        let mut sheet = tuple_sheet();
708        let cell_id = sheet.cell_names["pair"].0;
709        let mut labels = Labels::new();
710        labels.add_compound_cell(&sheet, cell_id, "pair");
711        let meta = labels.cells.get(&cell_id).unwrap();
712        let before = adam_lang::debug_compound_cell(&sheet, cell_id).unwrap();
713        let result = (meta.write_str)(&mut sheet, "(1, 2.0)");
714        assert!(result.is_err());
715        let after = adam_lang::debug_compound_cell(&sheet, cell_id).unwrap();
716        assert_eq!(before, after);
717    }
718
719    /// Builds real canonical tuple storage for host-display boundary contracts.
720    fn tuple_sheet() -> adam_lang::ParsedSheet {
721        adam_lang::AdamParser::new(adam_lang::TypeRegistry::new(), cel_parser::OpLookup::new())
722            .parse_str("sheet s { cell pair: (i32, f64) = (3, 4.5); }")
723            .unwrap()
724    }
725
726    /// A missing compound formatting capability stays visible without enabling edits.
727    #[test]
728    fn compound_label_reports_unavailable_storage_without_panicking() {
729        let mut sheet = AdamSheet::new();
730        let id = sheet.add_cell(7i32);
731        let mut labels = Labels::new();
732        labels.add_compound_cell(&sheet, id, "unavailable");
733        let meta = &labels.cells[&id];
734        assert_eq!((meta.display)(&sheet), "?");
735        assert!((meta.write_str)(&mut sheet, "9").is_err());
736        assert_eq!(*sheet.read::<i32>(id).unwrap(), 7);
737    }
738}