1use indexmap::IndexMap;
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use time::{
7 Date, OffsetDateTime, PrimitiveDateTime, Time, format_description::well_known::Rfc3339,
8};
9
10use crate::{
11 errors::{Diagnostic, McdError, Result},
12 manifest::{Manifest, TableManifestEntry},
13 package::McdPackage,
14 schema::{ColumnType, TableColumnSchema, TableSchema},
15};
16
17pub fn load_manifest_tables(
19 package: &McdPackage,
20 manifest: &Manifest,
21) -> Result<IndexMap<String, DataTable>> {
22 let mut tables = IndexMap::new();
23 for entry in &manifest.tables {
24 let table = DataTable::from_manifest_entry(package, entry)?;
25 tables.insert(entry.id.clone(), table);
26 }
27 Ok(tables)
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct DataTable {
34 pub id: String,
36 pub source: String,
38 pub schema: TableSchema,
40 pub rows: Vec<TableRow>,
42}
43
44impl DataTable {
45 pub fn from_manifest_entry(package: &McdPackage, entry: &TableManifestEntry) -> Result<Self> {
47 if !package.contains(&entry.data) {
48 return Err(McdError::from_diagnostic(
49 Diagnostic::error(
50 "table.data.missing",
51 format!("Declared table data file '{}' is missing.", entry.data),
52 )
53 .with_source(entry.data.clone()),
54 ));
55 }
56
57 let schema = TableSchema::from_package(package, &entry.schema)?;
58 if schema.id != entry.id {
59 return Err(McdError::from_diagnostic(
60 Diagnostic::error(
61 "schema.table.mismatch",
62 format!(
63 "Table schema id '{}' does not match manifest table id '{}'.",
64 schema.id, entry.id
65 ),
66 )
67 .with_source(entry.schema.clone()),
68 ));
69 }
70
71 let bytes = package.read(&entry.data)?;
72 let rows = load_csv_rows(&entry.id, &entry.data, &entry.schema, bytes, &schema)?;
73
74 Ok(Self {
75 id: entry.id.clone(),
76 source: entry.data.clone(),
77 schema,
78 rows,
79 })
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct TableRow {
86 #[serde(flatten)]
88 pub cells: IndexMap<String, TypedValue>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(tag = "type", content = "value", rename_all = "snake_case")]
94pub enum TypedValue {
95 Null,
97 String(String),
99 Integer(i64),
101 Decimal(String),
103 Boolean(bool),
105 Date(String),
107 Datetime(String),
109 Time(String),
111 Enum(String),
113}
114
115fn load_csv_rows(
116 table_id: &str,
117 source: &str,
118 schema_source: &str,
119 bytes: &[u8],
120 schema: &TableSchema,
121) -> Result<Vec<TableRow>> {
122 let mut reader = csv::ReaderBuilder::new()
123 .has_headers(true)
124 .flexible(false)
125 .from_reader(bytes);
126
127 let headers = reader.headers().map_err(|err| {
128 McdError::from_diagnostic(
129 Diagnostic::error(
130 "csv.header.missing",
131 format!("CSV table '{table_id}' must include a header row: {err}."),
132 )
133 .with_source(format!("{source}:1")),
134 )
135 })?;
136 if headers.is_empty() {
137 return Err(McdError::from_diagnostic(
138 Diagnostic::error(
139 "csv.header.missing",
140 format!("CSV table '{table_id}' must include a header row."),
141 )
142 .with_source(format!("{source}:1")),
143 ));
144 }
145
146 let actual_headers = headers.iter().map(ToOwned::to_owned).collect::<Vec<_>>();
147 let expected_headers = schema
148 .columns
149 .iter()
150 .map(|column| column.name.clone())
151 .collect::<Vec<_>>();
152 if actual_headers != expected_headers {
153 return Err(McdError::from_diagnostic(
154 Diagnostic::error(
155 "csv.header.mismatch",
156 format!(
157 "CSV header does not match table schema for table '{}'. Expected [{}], got [{}].",
158 table_id,
159 expected_headers.join(", "),
160 actual_headers.join(", ")
161 ),
162 )
163 .with_source(format!("{source}:1"))
164 .with_related(schema_source.to_owned()),
165 ));
166 }
167
168 let mut rows = Vec::new();
169 for (record_index, record) in reader.records().enumerate() {
170 let record = record.map_err(|err| {
171 McdError::from_diagnostic(
172 Diagnostic::error("csv.row.invalid", format!("Invalid CSV row: {err}."))
173 .with_source(format!("{source}:{}", record_index + 2)),
174 )
175 })?;
176
177 let mut cells = IndexMap::new();
178 for (column_index, column) in schema.columns.iter().enumerate() {
179 let raw = record.get(column_index).unwrap_or_default();
180 let value = coerce_cell(raw, column, source, record_index + 2)?;
181 cells.insert(column.name.clone(), value);
182 }
183 rows.push(TableRow { cells });
184 }
185
186 Ok(rows)
187}
188
189pub fn coerce_cell(
191 raw: &str,
192 column: &TableColumnSchema,
193 source: &str,
194 row_number: usize,
195) -> Result<TypedValue> {
196 let value = raw.trim();
197 if value.is_empty() {
198 if column.nullable {
199 return Ok(TypedValue::Null);
200 }
201 return Err(McdError::from_diagnostic(
202 Diagnostic::error(
203 "csv.cell.empty.nonnullable",
204 format!("Column '{}' does not allow empty cells.", column.name),
205 )
206 .with_source(format!("{source}:{row_number}")),
207 ));
208 }
209
210 match column.value_type {
211 ColumnType::String => Ok(TypedValue::String(raw.to_owned())),
212 ColumnType::Integer => value
213 .parse::<i64>()
214 .map(TypedValue::Integer)
215 .map_err(|_| cell_type_error("csv.cell.integer.invalid", column, source, row_number)),
216 ColumnType::Decimal => value
217 .parse::<Decimal>()
218 .map(|decimal| TypedValue::Decimal(decimal.normalize().to_string()))
219 .map_err(|_| cell_type_error("csv.cell.decimal.invalid", column, source, row_number)),
220 ColumnType::Boolean => parse_bool(value)
221 .map(TypedValue::Boolean)
222 .ok_or_else(|| cell_type_error("csv.cell.boolean.invalid", column, source, row_number)),
223 ColumnType::Date => Date::parse(
224 value,
225 &time::macros::format_description!("[year]-[month]-[day]"),
226 )
227 .map(|date| TypedValue::Date(date.to_string()))
228 .map_err(|_| cell_type_error("csv.cell.date.invalid", column, source, row_number)),
229 ColumnType::Datetime => parse_datetime(value)
230 .map(TypedValue::Datetime)
231 .ok_or_else(|| {
232 cell_type_error("csv.cell.datetime.invalid", column, source, row_number)
233 }),
234 ColumnType::Time => Time::parse(
235 value,
236 &time::macros::format_description!("[hour]:[minute]:[second]"),
237 )
238 .or_else(|_| Time::parse(value, &time::macros::format_description!("[hour]:[minute]")))
239 .map(|time| time.to_string())
240 .map(TypedValue::Time)
241 .map_err(|_| cell_type_error("csv.cell.time.invalid", column, source, row_number)),
242 ColumnType::Enum => {
243 if column.enum_values.iter().any(|allowed| allowed == value) {
244 Ok(TypedValue::Enum(value.to_owned()))
245 } else {
246 Err(McdError::from_diagnostic(
247 Diagnostic::error(
248 "csv.cell.enum.invalid",
249 format!(
250 "Value '{}' is not a member of enum column '{}'.",
251 value, column.name
252 ),
253 )
254 .with_source(format!("{source}:{row_number}")),
255 ))
256 }
257 }
258 }
259}
260
261fn parse_bool(value: &str) -> Option<bool> {
262 match value {
263 "true" | "TRUE" | "True" => Some(true),
264 "false" | "FALSE" | "False" => Some(false),
265 _ => None,
266 }
267}
268
269fn parse_datetime(value: &str) -> Option<String> {
270 OffsetDateTime::parse(value, &Rfc3339)
271 .map(|datetime| datetime.to_string())
272 .or_else(|_| {
273 PrimitiveDateTime::parse(
274 value,
275 &time::macros::format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]"),
276 )
277 .map(|datetime| datetime.to_string())
278 })
279 .ok()
280}
281
282fn cell_type_error(
283 code: &'static str,
284 column: &TableColumnSchema,
285 source: &str,
286 row_number: usize,
287) -> McdError {
288 McdError::from_diagnostic(
289 Diagnostic::error(
290 code,
291 format!(
292 "Cell in column '{}' is not a valid {:?}.",
293 column.name, column.value_type
294 ),
295 )
296 .with_source(format!("{source}:{row_number}")),
297 )
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use proptest::prelude::*;
304
305 fn column(value_type: ColumnType, nullable: bool) -> TableColumnSchema {
306 TableColumnSchema {
307 name: "value".to_owned(),
308 value_type,
309 label: None,
310 nullable,
311 enum_values: Vec::new(),
312 }
313 }
314
315 #[test]
316 fn coerces_decimal() {
317 let value = coerce_cell("12.3400", &column(ColumnType::Decimal, false), "t.csv", 2)
318 .expect("decimal");
319 assert_eq!(value, TypedValue::Decimal("12.34".to_owned()));
320 }
321
322 #[test]
323 fn rejects_empty_nonnullable_cell() {
324 let err =
325 coerce_cell("", &column(ColumnType::String, false), "t.csv", 2).expect_err("invalid");
326 assert_eq!(
327 err.diagnostic().map(|d| d.code.as_str()),
328 Some("csv.cell.empty.nonnullable")
329 );
330 }
331
332 #[test]
333 fn rejects_bad_date() {
334 let err = coerce_cell("2026-99-99", &column(ColumnType::Date, false), "t.csv", 2)
335 .expect_err("invalid");
336 assert_eq!(
337 err.diagnostic().map(|d| d.code.as_str()),
338 Some("csv.cell.date.invalid")
339 );
340 }
341
342 #[test]
343 fn rejects_bad_decimal_datetime_time_boolean_and_enum() {
344 let cases = [
345 (
346 "not-money",
347 TableColumnSchema {
348 name: "value".to_owned(),
349 value_type: ColumnType::Decimal,
350 label: None,
351 nullable: false,
352 enum_values: Vec::new(),
353 },
354 "csv.cell.decimal.invalid",
355 ),
356 (
357 "2026-05-18 12:00:00",
358 TableColumnSchema {
359 name: "value".to_owned(),
360 value_type: ColumnType::Datetime,
361 label: None,
362 nullable: false,
363 enum_values: Vec::new(),
364 },
365 "csv.cell.datetime.invalid",
366 ),
367 (
368 "25:00",
369 TableColumnSchema {
370 name: "value".to_owned(),
371 value_type: ColumnType::Time,
372 label: None,
373 nullable: false,
374 enum_values: Vec::new(),
375 },
376 "csv.cell.time.invalid",
377 ),
378 (
379 "yes",
380 TableColumnSchema {
381 name: "value".to_owned(),
382 value_type: ColumnType::Boolean,
383 label: None,
384 nullable: false,
385 enum_values: Vec::new(),
386 },
387 "csv.cell.boolean.invalid",
388 ),
389 (
390 "bronze",
391 TableColumnSchema {
392 name: "value".to_owned(),
393 value_type: ColumnType::Enum,
394 label: None,
395 nullable: false,
396 enum_values: vec!["silver".to_owned(), "gold".to_owned()],
397 },
398 "csv.cell.enum.invalid",
399 ),
400 ];
401
402 for (raw, column, code) in cases {
403 let err = coerce_cell(raw, &column, "t.csv", 2).expect_err("invalid");
404 assert_eq!(err.diagnostic().map(|d| d.code.as_str()), Some(code));
405 }
406 }
407
408 proptest! {
409 #[test]
410 fn coerces_generated_integers(raw in any::<i64>()) {
411 let value = coerce_cell(
412 &raw.to_string(),
413 &column(ColumnType::Integer, false),
414 "t.csv",
415 2,
416 )
417 .expect("integer");
418 prop_assert_eq!(value, TypedValue::Integer(raw));
419 }
420 }
421}