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#[derive(Debug, Clone)]
24pub struct ManifestBindings {
25 manifest: Manifest,
26 bindings: Vec<PortBinding>,
27}
28
29impl ManifestBindings {
30 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 pub fn manifest(&self) -> &Manifest {
56 &self.manifest
57 }
58
59 pub fn bindings(&self) -> &[PortBinding] {
61 &self.bindings
62 }
63
64 pub fn into_parts(self) -> (Manifest, Vec<PortBinding>) {
66 (self.manifest, self.bindings)
67 }
68
69 pub fn get(&self, id: &str) -> Option<&PortBinding> {
71 self.bindings.iter().find(|binding| binding.id == id)
72 }
73}
74
75#[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#[derive(Debug, Clone)]
208pub enum BoundPort {
209 Scalar(ScalarBinding),
210 Record(RecordBinding),
211 Range(RangeBinding),
212 Table(TableBinding),
213}
214
215#[derive(Debug, Clone)]
217pub struct ScalarBinding {
218 pub value_type: ValueType,
219 pub format: Option<String>,
220 pub location: ScalarLocation,
221}
222
223#[derive(Debug, Clone)]
225pub struct RangeBinding {
226 pub cell_type: ValueType,
227 pub format: Option<String>,
228 pub location: AreaLocation,
229}
230
231#[derive(Debug, Clone)]
233pub struct RecordBinding {
234 pub location: AreaLocation,
235 pub fields: BTreeMap<String, RecordFieldBinding>,
236}
237
238#[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#[derive(Debug, Clone)]
249pub struct TableBinding {
250 pub location: TableLocation,
251 pub columns: Vec<TableColumnBinding>,
252 pub keys: Vec<String>,
253}
254
255#[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}