Skip to content

Commit 99a4ba7

Browse files
committed
fix: adjust vehicle creation, validation and tests on merge actions
1 parent cfec63f commit 99a4ba7

16 files changed

Lines changed: 124 additions & 80 deletions

File tree

crates/ev-core/src/domain/vehicle.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ use crate::error::ValidationError;
1313
use crate::validation::Validate;
1414

1515
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16+
#[serde(deny_unknown_fields)]
1617
pub struct Vehicle {
18+
#[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
19+
pub schema_url: Option<String>,
1720
pub schema_version: String,
1821
pub make: SlugName,
1922
pub model: SlugName,
@@ -198,6 +201,7 @@ mod tests {
198201

199202
fn create_test_vehicle() -> Vehicle {
200203
Vehicle {
204+
schema_url: None,
201205
schema_version: "1.0.0".to_string(),
202206
make: SlugName {
203207
slug: "tesla".to_string(),

crates/ev-etl/src/ingest/reader.rs

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -107,19 +107,19 @@ fn parse_vehicle_file(path: &Path, base_dir: &Path) -> Result<Option<VehicleFile
107107
return Ok(None);
108108
}
109109

110-
let expected_base_name = format!("{}_{}", make_slug, model_slug);
111-
if file_name == expected_base_name
112-
|| file_name.starts_with(&format!("{}_", expected_base_name))
113-
{
114-
if file_name == expected_base_name {
115-
(Some(year), FileType::YearBase)
116-
} else {
117-
(Some(year), FileType::Variant)
118-
}
119-
} else if !file_name.contains('_') {
110+
// Strict naming convention check
111+
if file_name == model_slug {
120112
(Some(year), FileType::YearBase)
121-
} else {
113+
} else if file_name.starts_with(&format!("{}_", model_slug)) {
122114
(Some(year), FileType::Variant)
115+
} else {
116+
return Err(anyhow::anyhow!(
117+
"Invalid file name '{}.json' in year {} folder. Expected exact match '{}' or starts with '{}_'",
118+
file_name,
119+
year,
120+
model_slug,
121+
model_slug
122+
));
123123
}
124124
} else {
125125
return Ok(None);

crates/ev-etl/src/merge/mod.rs

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ mod strategy;
22

33
use std::collections::HashMap;
44

5-
use anyhow::Result;
5+
use anyhow::{Result, anyhow};
66
use ev_core::Vehicle;
77
use serde_json::Value;
88

@@ -19,11 +19,14 @@ pub fn merge_all(files: &[VehicleFile]) -> Result<Vec<Vehicle>> {
1919
}
2020

2121
let mut vehicles = Vec::new();
22+
let mut errors: Vec<String> = Vec::new();
2223

2324
for ((make_slug, model_slug), model_files) in grouped {
2425
let base_file = model_files
2526
.iter()
2627
.find(|f| f.file_type == FileType::ModelBase);
28+
29+
// Base file is optional for the model, but if missing, year files must be complete
2730
let base_content = base_file
2831
.map(|f| f.content.clone())
2932
.unwrap_or(Value::Object(serde_json::Map::new()));
@@ -46,39 +49,69 @@ pub fn merge_all(files: &[VehicleFile]) -> Result<Vec<Vehicle>> {
4649
.collect();
4750

4851
if let Some(year_base_file) = year_base {
49-
let merged_base = deep_merge(&base_content, &year_base_file.content);
52+
let merged_year_base = deep_merge(&base_content, &year_base_file.content);
5053

51-
match serde_json::from_value::<Vehicle>(merged_base.clone()) {
54+
match serde_json::from_value::<Vehicle>(merged_year_base.clone()) {
5255
Ok(vehicle) => vehicles.push(vehicle),
5356
Err(e) => {
54-
tracing::warn!(
55-
"Failed to parse base vehicle {}/{}/{}: {}",
57+
let msg = format!(
58+
"{}/{}/{}/{}: {}",
5659
make_slug,
5760
model_slug,
5861
year,
62+
year_base_file
63+
.path
64+
.file_name()
65+
.unwrap_or_default()
66+
.to_string_lossy(),
5967
e
6068
);
69+
errors.push(msg);
6170
}
6271
}
6372

6473
for variant_file in variants {
65-
let merged_variant = deep_merge(&merged_base, &variant_file.content);
74+
let merged_variant = deep_merge(&merged_year_base, &variant_file.content);
6675

6776
match serde_json::from_value::<Vehicle>(merged_variant) {
6877
Ok(vehicle) => vehicles.push(vehicle),
6978
Err(e) => {
70-
tracing::warn!(
71-
"Failed to parse variant {:?}: {}",
72-
variant_file.path,
79+
let msg = format!(
80+
"{}/{}/{}/{}: {}",
81+
make_slug,
82+
model_slug,
83+
year,
84+
variant_file
85+
.path
86+
.file_name()
87+
.unwrap_or_default()
88+
.to_string_lossy(),
7389
e
7490
);
91+
errors.push(msg);
7592
}
7693
}
7794
}
95+
} else {
96+
// Critical Error: Year Base file missing
97+
let msg = format!(
98+
"Missing Year Base file for {}/{} (Year {}). Expected file named '{}.json'",
99+
make_slug, model_slug, year, model_slug
100+
);
101+
errors.push(msg);
78102
}
79103
}
80104
}
81105

106+
if !errors.is_empty() {
107+
let error_msg = format!(
108+
"Failed to parse {} vehicle(s):\n{}",
109+
errors.len(),
110+
errors.join("\n")
111+
);
112+
return Err(anyhow!(error_msg));
113+
}
114+
82115
vehicles.sort_by(|a, b| {
83116
a.make
84117
.slug

fixtures/sample_vehicles/byd/dolphin/2024/byd_dolphin.json renamed to fixtures/sample_vehicles/byd/dolphin/2024/dolphin.json

File renamed without changes.

fixtures/sample_vehicles/tesla/model_3/2024/tesla_model_3.json renamed to fixtures/sample_vehicles/tesla/model_3/2024/model_3.json

File renamed without changes.

tests/unit/ev-core/domain/vehicle_test.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use ev_core::{Battery, Charging, Powertrain, Range, SlugName, Vehicle, VehicleTy
33
fn create_base_vehicle() -> Vehicle {
44
use ev_core::Drivetrain;
55
Vehicle {
6+
schema_url: None,
67
schema_version: "1.0.0".to_string(),
78
make: SlugName {
89
slug: "tesla".into(),

tests/unit/ev-etl/ingest/reader_test.rs

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ fn test_scan_directory_structure() {
1515
// Create structure: tesla/model_y/2024/tesla_model_y.json (YearBase)
1616
let year_dir = model_dir.join("2024");
1717
fs::create_dir_all(&year_dir).unwrap();
18-
fs::write(year_dir.join("tesla_model_y.json"), "{}").unwrap();
18+
fs::write(year_dir.join("model_y.json"), "{}").unwrap();
1919

20-
// Create structure: tesla/model_y/2024/tesla_model_y_perf.json (Variant)
21-
fs::write(year_dir.join("tesla_model_y_perf.json"), "{}").unwrap();
20+
// Create structure: tesla/model_y/2024/model_y_perf.json (Variant)
21+
fs::write(year_dir.join("model_y_perf.json"), "{}").unwrap();
2222

2323
let files = scan_directory(root).unwrap();
2424

@@ -72,7 +72,7 @@ fn test_scan_directory_hidden_files() {
7272
// Hidden file should be ignored
7373
fs::write(year_dir.join(".hidden.json"), "{}").unwrap();
7474
// Valid file
75-
fs::write(year_dir.join("tesla_model_3.json"), "{}").unwrap();
75+
fs::write(year_dir.join("model_3.json"), "{}").unwrap();
7676

7777
let files = scan_directory(root).unwrap();
7878
assert_eq!(files.len(), 1);
@@ -103,7 +103,7 @@ fn test_scan_directory_year_base_without_underscore() {
103103
let year_dir = model_dir.join("2024");
104104
fs::create_dir_all(&year_dir).unwrap();
105105
// File without underscore pattern should still be YearBase
106-
fs::write(year_dir.join("base.json"), "{}").unwrap();
106+
fs::write(year_dir.join("dolphin.json"), "{}").unwrap();
107107

108108
let files = scan_directory(root).unwrap();
109109
assert_eq!(files.len(), 1);
@@ -119,8 +119,8 @@ fn test_scan_directory_sorting() {
119119
let year_dir = model_dir.join("2024");
120120
fs::create_dir_all(&year_dir).unwrap();
121121
fs::write(model_dir.join("base.json"), "{}").unwrap();
122-
fs::write(year_dir.join("tesla_model_3.json"), "{}").unwrap();
123-
fs::write(year_dir.join("tesla_model_3_lr.json"), "{}").unwrap();
122+
fs::write(year_dir.join("model_3.json"), "{}").unwrap();
123+
fs::write(year_dir.join("model_3_lr.json"), "{}").unwrap();
124124

125125
let files = scan_directory(root).unwrap();
126126
assert_eq!(files.len(), 3);
@@ -141,7 +141,7 @@ fn test_scan_directory_non_json_files() {
141141
fs::create_dir_all(&year_dir).unwrap();
142142
fs::write(year_dir.join("readme.txt"), "text").unwrap();
143143
fs::write(year_dir.join("config.yaml"), "yaml").unwrap();
144-
fs::write(year_dir.join("tesla_model_3.json"), "{}").unwrap();
144+
fs::write(year_dir.join("model_3.json"), "{}").unwrap();
145145

146146
let files = scan_directory(root).unwrap();
147147
assert_eq!(files.len(), 1);
@@ -162,12 +162,12 @@ fn test_scan_directory_multiple_makes() {
162162
// Tesla
163163
let tesla_dir = root.join("tesla").join("model_3").join("2024");
164164
fs::create_dir_all(&tesla_dir).unwrap();
165-
fs::write(tesla_dir.join("tesla_model_3.json"), "{}").unwrap();
165+
fs::write(tesla_dir.join("model_3.json"), "{}").unwrap();
166166

167167
// BYD
168168
let byd_dir = root.join("byd").join("dolphin").join("2024");
169169
fs::create_dir_all(&byd_dir).unwrap();
170-
fs::write(byd_dir.join("byd_dolphin.json"), "{}").unwrap();
170+
fs::write(byd_dir.join("dolphin.json"), "{}").unwrap();
171171

172172
let files = scan_directory(root).unwrap();
173173
assert_eq!(files.len(), 2);
@@ -186,12 +186,13 @@ fn test_scan_directory_variant_with_underscore() {
186186
let year_dir = model_dir.join("2024");
187187
fs::create_dir_all(&year_dir).unwrap();
188188

189-
// YearBase file: tesla_model_3.json
190-
fs::write(year_dir.join("tesla_model_3.json"), "{}").unwrap();
191-
// Variant file with underscore suffix: tesla_model_3_long_range.json
192-
fs::write(year_dir.join("tesla_model_3_long_range.json"), "{}").unwrap();
189+
// YearBase file: model_3.json
190+
fs::write(year_dir.join("model_3.json"), "{}").unwrap();
191+
// Variant file with underscore suffix: model_3_long_range.json
192+
fs::write(year_dir.join("model_3_long_range.json"), "{}").unwrap();
193193
// Another variant: a file with underscore but not matching expected pattern exactly
194-
fs::write(year_dir.join("some_other_variant.json"), "{}").unwrap();
194+
// UPDATE: to be valid, must start with model_slug
195+
fs::write(year_dir.join("model_3_other.json"), "{}").unwrap();
195196

196197
let files = scan_directory(root).unwrap();
197198

tests/unit/ev-etl/lib_test.rs

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ fn create_valid_test_dataset() -> TempDir {
2626
});
2727

2828
std::fs::write(
29-
base.join("tesla/model_3/2024/tesla_model_3.json"),
29+
base.join("tesla/model_3/2024/model_3.json"),
3030
serde_json::to_string_pretty(&vehicle).unwrap(),
3131
)
3232
.expect("Failed to write");
@@ -244,8 +244,9 @@ fn create_invalid_test_dataset() -> TempDir {
244244

245245
std::fs::create_dir_all(base.join("tesla/model_3/2024")).expect("Failed to create dirs");
246246

247-
// Invalid vehicle: missing battery capacity, charge ports, range, sources
247+
// Invalid vehicle: missing battery capacity, charge ports, range, sources AND unknown field
248248
let vehicle = json!({
249+
"unknown_field": "should_fail",
249250
"schema_version": "1.0.0",
250251
"make": {"slug": "tesla", "name": "Tesla"},
251252
"model": {"slug": "model_3", "name": "Model 3"},
@@ -261,7 +262,7 @@ fn create_invalid_test_dataset() -> TempDir {
261262
});
262263

263264
std::fs::write(
264-
base.join("tesla/model_3/2024/tesla_model_3.json"),
265+
base.join("tesla/model_3/2024/model_3.json"),
265266
serde_json::to_string_pretty(&vehicle).unwrap(),
266267
)
267268
.expect("Failed to write");
@@ -285,7 +286,7 @@ fn test_run_validation_with_invalid_vehicle() {
285286
}
286287

287288
#[test]
288-
fn test_run_pipeline_filters_invalid_vehicles() {
289+
fn test_run_pipeline_fails_on_invalid_vehicles() {
289290
let temp_dir = TempDir::new().expect("Failed to create temp dir");
290291
let base = temp_dir.path();
291292

@@ -308,8 +309,9 @@ fn test_run_pipeline_filters_invalid_vehicles() {
308309
"sources": [{"type": "oem", "title": "Tesla", "url": "https://tesla.com", "accessed_at": "2024-01-01"}]
309310
});
310311

311-
// Invalid vehicle: missing required fields
312+
// Invalid vehicle: missing required fields AND has unknown field
312313
let invalid_vehicle = json!({
314+
"unknown_field": "should_fail",
313315
"schema_version": "1.0.0",
314316
"make": {"slug": "byd", "name": "BYD"},
315317
"model": {"slug": "dolphin", "name": "Dolphin"},
@@ -325,13 +327,13 @@ fn test_run_pipeline_filters_invalid_vehicles() {
325327
});
326328

327329
std::fs::write(
328-
base.join("tesla/model_3/2024/tesla_model_3.json"),
330+
base.join("tesla/model_3/2024/model_3.json"),
329331
serde_json::to_string_pretty(&valid_vehicle).unwrap(),
330332
)
331333
.expect("Failed to write");
332334

333335
std::fs::write(
334-
base.join("byd/dolphin/2024/byd_dolphin.json"),
336+
base.join("byd/dolphin/2024/dolphin.json"),
335337
serde_json::to_string_pretty(&invalid_vehicle).unwrap(),
336338
)
337339
.expect("Failed to write");
@@ -345,14 +347,7 @@ fn test_run_pipeline_filters_invalid_vehicles() {
345347
false,
346348
);
347349

348-
// Pipeline should succeed but skip invalid vehicle
350+
// Pipeline should fail due to invalid vehicle (fail-fast)
349351
let result = run_pipeline(&cli);
350-
assert!(result.is_ok());
351-
352-
// Verify output contains only valid vehicle
353-
let output_json = std::fs::read_to_string(output_dir.path().join("vehicles.json")).unwrap();
354-
let output: serde_json::Value = serde_json::from_str(&output_json).unwrap();
355-
356-
// Should have only 1 vehicle (Tesla, not BYD)
357-
assert_eq!(output["vehicle_count"], 1);
352+
assert!(result.is_err());
358353
}

tests/unit/ev-etl/merge/merge_test.rs

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ fn create_test_dataset() -> TempDir {
2828
});
2929

3030
std::fs::write(
31-
base.join("tesla/model_3/2024/tesla_model_3.json"),
31+
base.join("tesla/model_3/2024/model_3.json"),
3232
serde_json::to_string_pretty(&year_base).unwrap(),
3333
)
3434
.expect("Failed to write year base");
@@ -51,7 +51,7 @@ fn create_test_dataset() -> TempDir {
5151
});
5252

5353
std::fs::write(
54-
base.join("tesla/model_3/2024/tesla_model_3_long_range.json"),
54+
base.join("tesla/model_3/2024/model_3_long_range.json"),
5555
serde_json::to_string_pretty(&variant).unwrap(),
5656
)
5757
.expect("Failed to write variant");
@@ -248,16 +248,15 @@ fn test_merge_all_invalid_base_vehicle() {
248248
});
249249

250250
std::fs::write(
251-
base.join("tesla/model_3/2024/tesla_model_3.json"),
251+
base.join("tesla/model_3/2024/model_3.json"),
252252
serde_json::to_string_pretty(&invalid_base).unwrap(),
253253
)
254254
.expect("Failed to write");
255255

256256
let files = ev_etl::ingest::load_dataset(base).expect("Failed to load dataset");
257257

258-
let vehicles = ev_etl::merge::merge_all(&files).expect("Failed to merge");
259-
260-
assert!(vehicles.is_empty());
258+
let vehicles = ev_etl::merge::merge_all(&files);
259+
assert!(vehicles.is_err());
261260
}
262261

263262
#[test]
@@ -283,7 +282,7 @@ fn test_merge_all_invalid_variant_vehicle() {
283282
});
284283

285284
std::fs::write(
286-
base.join("tesla/model_3/2024/tesla_model_3.json"),
285+
base.join("tesla/model_3/2024/model_3.json"),
287286
serde_json::to_string_pretty(&valid_base).unwrap(),
288287
)
289288
.expect("Failed to write base");
@@ -294,15 +293,13 @@ fn test_merge_all_invalid_variant_vehicle() {
294293
});
295294

296295
std::fs::write(
297-
base.join("tesla/model_3/2024/tesla_model_3_invalid.json"),
296+
base.join("tesla/model_3/2024/model_3_invalid.json"),
298297
serde_json::to_string_pretty(&invalid_variant).unwrap(),
299298
)
300299
.expect("Failed to write variant");
301300

302301
let files = ev_etl::ingest::load_dataset(base).expect("Failed to load dataset");
303302

304-
let vehicles = ev_etl::merge::merge_all(&files).expect("Failed to merge");
305-
306-
assert_eq!(vehicles.len(), 1);
307-
assert_eq!(vehicles[0].trim.slug, "base");
303+
let vehicles = ev_etl::merge::merge_all(&files);
304+
assert!(vehicles.is_err());
308305
}

0 commit comments

Comments
 (0)