Skip to main content

formualizer_sheetport/
binding.rs

1use crate::error::SheetPortError;
2use crate::location::{AreaLocation, FieldLocation, ScalarLocation, TableLocation};
3use crate::resolver::{
4    resolve_area_location, resolve_field_location, resolve_scalar_location, resolve_table_location,
5};
6use crate::value::{PortValue, TableRow, TableValue};
7use chrono::{NaiveDate, NaiveDateTime};
8use serde_json::Value as JsonValue;
9use sheetport_spec::{
10    Constraints, Direction, Manifest, ManifestIssue, Port, Profile, RecordSchema, Schema, Shape,
11    TableSchema, Units, ValueType,
12};
13use std::collections::BTreeMap;
14
15fn profile_label(profile: Profile) -> &'static str {
16    match profile {
17        Profile::CoreV0 => "core-v0",
18        Profile::FullV0 => "full-v0",
19    }
20}
21
22/// Bound manifest along with per-port selector metadata.
23#[derive(Debug, Clone)]
24pub struct ManifestBindings {
25    manifest: Manifest,
26    bindings: Vec<PortBinding>,
27}
28
29impl ManifestBindings {
30    /// Validate and bind a manifest into runtime-friendly structures.
31    pub fn new(manifest: Manifest) -> Result<Self, SheetPortError> {
32        manifest.validate()?;
33
34        let profile = manifest.effective_profile();
35        if profile != Profile::CoreV0 {
36            return Err(SheetPortError::InvalidManifest {
37                issues: vec![ManifestIssue::new(
38                    "capabilities.profile",
39                    format!(
40                        "profile `{}` is not supported by this runtime (supported: core-v0)",
41                        profile_label(profile)
42                    ),
43                )],
44            });
45        }
46
47        let mut bindings = Vec::with_capacity(manifest.ports.len());
48        for (idx, port) in manifest.ports.iter().enumerate() {
49            bindings.push(PortBinding::bind(idx, port)?);
50        }
51        Ok(Self { manifest, bindings })
52    }
53
54    /// Access the original manifest.
55    pub fn manifest(&self) -> &Manifest {
56        &self.manifest
57    }
58
59    /// Retrieve the bound ports in declaration order.
60    pub fn bindings(&self) -> &[PortBinding] {
61        &self.bindings
62    }
63
64    /// Consume the bindings and return owned components.
65    pub fn into_parts(self) -> (Manifest, Vec<PortBinding>) {
66        (self.manifest, self.bindings)
67    }
68
69    /// Locate a bound port by id.
70    pub fn get(&self, id: &str) -> Option<&PortBinding> {
71        self.bindings.iter().find(|binding| binding.id == id)
72    }
73}
74
75/// Fully resolved port description.
76#[derive(Debug, Clone)]
77pub struct PortBinding {
78    pub index: usize,
79    pub id: String,
80    pub direction: Direction,
81    pub required: bool,
82    pub description: Option<String>,
83    pub constraints: Option<Constraints>,
84    pub units: Option<Units>,
85    pub default: Option<JsonValue>,
86    pub resolved_default: Option<PortValue>,
87    pub partition_key: bool,
88    pub kind: BoundPort,
89}
90
91impl PortBinding {
92    fn bind(index: usize, port: &Port) -> Result<Self, SheetPortError> {
93        let (kind, resolved_default) = match (&port.shape, &port.schema) {
94            (Shape::Scalar, Schema::Scalar(schema)) => {
95                let location = resolve_scalar_location(&port.id, &port.location)?;
96                let default = port
97                    .default
98                    .as_ref()
99                    .map(|value| {
100                        literal_from_json(&port.id, port.id.as_str(), schema.value_type, value)
101                            .map(PortValue::Scalar)
102                    })
103                    .transpose()?;
104                (
105                    BoundPort::Scalar(ScalarBinding {
106                        value_type: schema.value_type,
107                        format: schema.format.clone(),
108                        location,
109                    }),
110                    default,
111                )
112            }
113            (Shape::Record, Schema::Record(schema)) => {
114                let location = resolve_area_location(&port.id, &port.location)?;
115                let mut fields = BTreeMap::new();
116                for (name, field) in schema.fields.iter() {
117                    let location = resolve_field_location(&port.id, name, &field.location)?;
118                    fields.insert(
119                        name.to_string(),
120                        RecordFieldBinding {
121                            value_type: field.value_type,
122                            constraints: field.constraints.clone(),
123                            units: field.units.clone(),
124                            location,
125                        },
126                    );
127                }
128                let default = port
129                    .default
130                    .as_ref()
131                    .map(|value| convert_record_default(&port.id, schema, value))
132                    .transpose()?;
133                (
134                    BoundPort::Record(RecordBinding { location, fields }),
135                    default,
136                )
137            }
138            (Shape::Range, Schema::Range(schema)) => {
139                let location = resolve_area_location(&port.id, &port.location)?;
140                let default = port
141                    .default
142                    .as_ref()
143                    .map(|value| convert_range_default(&port.id, schema.cell_type, value))
144                    .transpose()?;
145                (
146                    BoundPort::Range(RangeBinding {
147                        cell_type: schema.cell_type,
148                        format: schema.format.clone(),
149                        location,
150                    }),
151                    default,
152                )
153            }
154            (Shape::Table, Schema::Table(schema)) => {
155                let location = resolve_table_location(&port.id, &port.location)?;
156                let columns = schema
157                    .columns
158                    .iter()
159                    .map(|col| TableColumnBinding {
160                        name: col.name.clone(),
161                        value_type: col.value_type,
162                        column_hint: col.col.clone(),
163                        format: col.format.clone(),
164                        units: col.units.clone(),
165                    })
166                    .collect::<Vec<_>>();
167                let default = port
168                    .default
169                    .as_ref()
170                    .map(|value| convert_table_default(&port.id, schema, value))
171                    .transpose()?;
172                let keys = schema.keys.clone().unwrap_or_default();
173                (
174                    BoundPort::Table(TableBinding {
175                        location,
176                        columns,
177                        keys,
178                    }),
179                    default,
180                )
181            }
182            _ => {
183                return Err(SheetPortError::InvariantViolation {
184                    port: port.id.clone(),
185                    message: "port shape and schema are inconsistent".to_string(),
186                });
187            }
188        };
189
190        Ok(Self {
191            index,
192            id: port.id.clone(),
193            direction: port.dir,
194            required: port.required,
195            description: port.description.clone(),
196            constraints: port.constraints.clone(),
197            units: port.units.clone(),
198            default: port.default.clone(),
199            resolved_default,
200            partition_key: port.partition_key.unwrap_or(false),
201            kind,
202        })
203    }
204}
205
206/// Union of bound port kinds.
207#[derive(Debug, Clone)]
208pub enum BoundPort {
209    Scalar(ScalarBinding),
210    Record(RecordBinding),
211    Range(RangeBinding),
212    Table(TableBinding),
213}
214
215/// Scalar port binding.
216#[derive(Debug, Clone)]
217pub struct ScalarBinding {
218    pub value_type: ValueType,
219    pub format: Option<String>,
220    pub location: ScalarLocation,
221}
222
223/// Range port binding.
224#[derive(Debug, Clone)]
225pub struct RangeBinding {
226    pub cell_type: ValueType,
227    pub format: Option<String>,
228    pub location: AreaLocation,
229}
230
231/// Record port binding with per-field metadata.
232#[derive(Debug, Clone)]
233pub struct RecordBinding {
234    pub location: AreaLocation,
235    pub fields: BTreeMap<String, RecordFieldBinding>,
236}
237
238/// Metadata describing an individual record field binding.
239#[derive(Debug, Clone)]
240pub struct RecordFieldBinding {
241    pub value_type: ValueType,
242    pub constraints: Option<Constraints>,
243    pub units: Option<Units>,
244    pub location: FieldLocation,
245}
246
247/// Table port binding with column descriptors.
248#[derive(Debug, Clone)]
249pub struct TableBinding {
250    pub location: TableLocation,
251    pub columns: Vec<TableColumnBinding>,
252    pub keys: Vec<String>,
253}
254
255/// Individual table column binding.
256#[derive(Debug, Clone)]
257pub struct TableColumnBinding {
258    pub name: String,
259    pub value_type: ValueType,
260    pub column_hint: Option<String>,
261    pub format: Option<String>,
262    pub units: Option<Units>,
263}
264
265fn convert_record_default(
266    port_id: &str,
267    schema: &RecordSchema,
268    value: &JsonValue,
269) -> Result<PortValue, SheetPortError> {
270    let obj = value
271        .as_object()
272        .ok_or_else(|| SheetPortError::InvariantViolation {
273            port: port_id.to_string(),
274            message: "record defaults must be objects".to_string(),
275        })?;
276    let mut map = BTreeMap::new();
277    for (key, json_value) in obj {
278        let field = schema
279            .fields
280            .get(key)
281            .ok_or_else(|| SheetPortError::InvariantViolation {
282                port: port_id.to_string(),
283                message: format!("record default references unknown field `{key}`"),
284            })?;
285        let literal = literal_from_json(
286            port_id,
287            &format!("{port_id}.{key}"),
288            field.value_type,
289            json_value,
290        )?;
291        map.insert(key.clone(), literal);
292    }
293    Ok(PortValue::Record(map))
294}
295
296fn convert_range_default(
297    port_id: &str,
298    cell_type: ValueType,
299    value: &JsonValue,
300) -> Result<PortValue, SheetPortError> {
301    let rows = value
302        .as_array()
303        .ok_or_else(|| SheetPortError::InvariantViolation {
304            port: port_id.to_string(),
305            message: "range defaults must be arrays of arrays".to_string(),
306        })?;
307    let mut grid = Vec::with_capacity(rows.len());
308    let mut expected_width: Option<usize> = None;
309    for (row_idx, row_value) in rows.iter().enumerate() {
310        let row = row_value
311            .as_array()
312            .ok_or_else(|| SheetPortError::InvariantViolation {
313                port: port_id.to_string(),
314                message: format!("range default row {row_idx} must be an array of scalar values"),
315            })?;
316        let mut converted_row = Vec::with_capacity(row.len());
317        for (col_idx, cell_json) in row.iter().enumerate() {
318            let literal = literal_from_json(
319                port_id,
320                &format!("{port_id}[r{},c{}]", row_idx + 1, col_idx + 1),
321                cell_type,
322                cell_json,
323            )?;
324            converted_row.push(literal);
325        }
326        if let Some(width) = expected_width {
327            if width != converted_row.len() {
328                return Err(SheetPortError::InvariantViolation {
329                    port: port_id.to_string(),
330                    message: format!(
331                        "range default row {row_idx} has width {}, expected {width}",
332                        converted_row.len()
333                    ),
334                });
335            }
336        } else {
337            expected_width = Some(converted_row.len());
338        }
339        grid.push(converted_row);
340    }
341    Ok(PortValue::Range(grid))
342}
343
344fn convert_table_default(
345    port_id: &str,
346    schema: &TableSchema,
347    value: &JsonValue,
348) -> Result<PortValue, SheetPortError> {
349    let rows = value
350        .as_array()
351        .ok_or_else(|| SheetPortError::InvariantViolation {
352            port: port_id.to_string(),
353            message: "table defaults must be arrays of objects".to_string(),
354        })?;
355    let mut converted_rows = Vec::with_capacity(rows.len());
356    for (row_idx, row_value) in rows.iter().enumerate() {
357        let obj = row_value
358            .as_object()
359            .ok_or_else(|| SheetPortError::InvariantViolation {
360                port: port_id.to_string(),
361                message: format!("table default row {row_idx} must be an object"),
362            })?;
363        let mut values = BTreeMap::new();
364        for column in &schema.columns {
365            let key = &column.name;
366            let cell_json = obj
367                .get(key)
368                .ok_or_else(|| SheetPortError::InvariantViolation {
369                    port: port_id.to_string(),
370                    message: format!("table default row {row_idx} missing column `{key}`"),
371                })?;
372            let literal = literal_from_json(
373                port_id,
374                &format!("{port_id}[{row_idx}].{key}"),
375                column.value_type,
376                cell_json,
377            )?;
378            values.insert(key.clone(), literal);
379        }
380
381        for unknown in obj.keys() {
382            if !schema.columns.iter().any(|col| col.name == *unknown) {
383                return Err(SheetPortError::InvariantViolation {
384                    port: port_id.to_string(),
385                    message: format!("table default references unknown column `{unknown}`"),
386                });
387            }
388        }
389
390        converted_rows.push(TableRow::new(values));
391    }
392    Ok(PortValue::Table(TableValue::new(converted_rows)))
393}
394
395fn literal_from_json(
396    port_id: &str,
397    path: &str,
398    value_type: ValueType,
399    value: &JsonValue,
400) -> Result<formualizer_common::LiteralValue, SheetPortError> {
401    use formualizer_common::LiteralValue as L;
402    match value {
403        JsonValue::Null => Ok(L::Empty),
404        JsonValue::Bool(b) => match value_type {
405            ValueType::Boolean => Ok(L::Boolean(*b)),
406            _ => Err(default_type_error(port_id, path, "boolean", value_type)),
407        },
408        JsonValue::Number(n) => match value_type {
409            ValueType::Number => {
410                if let Some(num) = n.as_f64() {
411                    Ok(L::Number(num))
412                } else {
413                    Err(default_message(
414                        port_id,
415                        path,
416                        "number default must be a finite numeric value",
417                    ))
418                }
419            }
420            ValueType::Integer => {
421                if let Some(i) = n.as_i64() {
422                    Ok(L::Int(i))
423                } else if let Some(f) = n.as_f64() {
424                    if (f - f.trunc()).abs() < f64::EPSILON {
425                        Ok(L::Int(f as i64))
426                    } else {
427                        Err(default_message(
428                            port_id,
429                            path,
430                            "integer default must be a whole number",
431                        ))
432                    }
433                } else {
434                    Err(default_message(
435                        port_id,
436                        path,
437                        "integer default must be representable as i64",
438                    ))
439                }
440            }
441            ValueType::String | ValueType::Date | ValueType::Datetime | ValueType::Boolean => {
442                Err(default_type_error(port_id, path, "number", value_type))
443            }
444        },
445        JsonValue::String(s) => match value_type {
446            ValueType::String => Ok(L::Text(s.clone())),
447            ValueType::Number => s
448                .parse::<f64>()
449                .map(L::Number)
450                .map_err(|_| default_message(port_id, path, "number default must be numeric")),
451            ValueType::Integer => s.parse::<i64>().map(L::Int).map_err(|_| {
452                default_message(port_id, path, "integer default must be a whole number")
453            }),
454            ValueType::Boolean => match s.to_ascii_lowercase().as_str() {
455                "true" => Ok(L::Boolean(true)),
456                "false" => Ok(L::Boolean(false)),
457                _ => Err(default_message(
458                    port_id,
459                    path,
460                    "boolean default strings must be `true` or `false`",
461                )),
462            },
463            ValueType::Date => {
464                let date = NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| {
465                    default_message(port_id, path, "date defaults must use YYYY-MM-DD format")
466                })?;
467                Ok(L::Date(date))
468            }
469            ValueType::Datetime => {
470                let dt = parse_datetime_string(s)
471                    .ok_or_else(|| default_message(port_id, path, "invalid datetime default"))?;
472                Ok(L::DateTime(dt))
473            }
474        },
475        JsonValue::Array(_) | JsonValue::Object(_) => Err(SheetPortError::InvariantViolation {
476            port: port_id.to_string(),
477            message: format!("invalid default at `{path}`: expected scalar value"),
478        }),
479    }
480}
481
482fn parse_datetime_string(raw: &str) -> Option<NaiveDateTime> {
483    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(raw) {
484        return Some(dt.naive_utc());
485    }
486    NaiveDateTime::parse_from_str(raw, "%Y-%m-%d %H:%M:%S")
487        .or_else(|_| NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S"))
488        .ok()
489}
490
491fn default_type_error(
492    port_id: &str,
493    path: &str,
494    expected: &str,
495    actual: ValueType,
496) -> SheetPortError {
497    SheetPortError::InvariantViolation {
498        port: port_id.to_string(),
499        message: format!(
500            "invalid default at `{path}`: expected {expected}, but port type is `{actual:?}`"
501        ),
502    }
503}
504
505fn default_message(port_id: &str, path: &str, message: &str) -> SheetPortError {
506    SheetPortError::InvariantViolation {
507        port: port_id.to_string(),
508        message: format!("invalid default at `{path}`: {message}"),
509    }
510}