1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use vespertide_config::VespertideConfig;
6use vespertide_core::TableDef;
7use vespertide_planner::validate_schema;
8
9use crate::parallel_config::map_paths_with_threshold;
10
11pub fn load_models(config: &VespertideConfig) -> Result<Vec<TableDef>> {
13 let models_dir = config.models_dir();
14 if !models_dir.exists() {
15 return Ok(Vec::new());
16 }
17
18 let mut tables = Vec::new();
19 load_models_recursive(models_dir, &mut tables)?;
20
21 if !tables.is_empty() {
24 let normalized_tables: Vec<TableDef> = tables
25 .iter()
26 .map(|t| {
27 t.normalize()
28 .map_err(|e| anyhow::anyhow!("Failed to normalize table '{}': {}", t.name, e))
29 })
30 .collect::<Result<Vec<_>, _>>()?;
31
32 validate_schema(&normalized_tables)
33 .map_err(|e| anyhow::anyhow!("schema validation failed: {e}"))?;
34 }
35
36 Ok(tables)
37}
38
39fn load_models_recursive(dir: &Path, tables: &mut Vec<TableDef>) -> Result<()> {
41 let paths = collect_model_paths(dir)?;
42 let results = map_paths_with_threshold(&paths, load_model_file);
43
44 for result in results {
45 tables.push(result?);
46 }
47
48 Ok(())
49}
50
51fn collect_model_paths(dir: &Path) -> Result<Vec<PathBuf>> {
52 let entries =
53 fs::read_dir(dir).with_context(|| format!("read models directory: {}", dir.display()))?;
54 let mut paths = Vec::new();
55
56 for entry in entries {
57 let entry = entry.context("read directory entry")?;
58 let path = entry.path();
59
60 let ft = entry.file_type().ok();
65 let is_symlink = ft.is_some_and(|t| t.is_symlink());
66
67 if ft.is_some_and(|t| t.is_dir()) || (is_symlink && path.is_dir()) {
68 paths.extend(collect_model_paths(&path)?);
69 } else if (ft.is_some_and(|t| t.is_file()) || (is_symlink && path.is_file()))
70 && crate::has_supported_extension(&path)
71 {
72 paths.push(path);
73 }
74 }
75
76 Ok(paths)
77}
78
79fn load_model_file(path: &Path) -> Result<TableDef> {
80 let ext = path.extension().and_then(|s| s.to_str());
81 let content =
82 fs::read_to_string(path).with_context(|| format!("read model file: {}", path.display()))?;
83
84 let table: TableDef = if ext == Some("json") {
85 serde_json::from_str(&content)
86 .with_context(|| format!("parse JSON model: {}", path.display()))?
87 } else {
88 serde_yaml::from_str(&content)
89 .with_context(|| format!("parse YAML model: {}", path.display()))?
90 };
91
92 table
93 .validate_unique_column_names()
94 .with_context(|| format!("validate model: {}", path.display()))?;
95
96 Ok(table)
97}
98
99pub fn load_models_from_dir(
101 project_root: Option<PathBuf>,
102) -> Result<Vec<TableDef>, Box<dyn std::error::Error>> {
103 let project_root = if let Some(root) = project_root {
105 root
106 } else {
107 PathBuf::from(
108 std::env::var("CARGO_MANIFEST_DIR")
109 .context("CARGO_MANIFEST_DIR environment variable not set")?,
110 )
111 };
112
113 let config = crate::config::load_config_or_default(Some(project_root.clone()))
115 .map_err(|e| format!("Failed to load config: {e}"))?;
116
117 let models_dir = project_root.join(config.models_dir());
119 if !models_dir.exists() {
120 return Ok(Vec::new());
121 }
122
123 let mut tables = Vec::new();
124 load_models_recursive_internal(&models_dir, &mut tables)
125 .map_err(|e| format!("Failed to load models: {e}"))?;
126
127 Ok(tables)
128}
129
130fn load_models_recursive_internal(
132 dir: &Path,
133 tables: &mut Vec<TableDef>,
134) -> Result<(), Box<dyn std::error::Error>> {
135 let paths = collect_model_paths(dir).map_err(|e| e.to_string())?;
136 let results = map_paths_with_threshold(&paths, load_normalized_model_file_internal);
137
138 for result in results {
139 tables.push(result.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?);
140 }
141
142 Ok(())
143}
144
145fn load_normalized_model_file_internal(path: &Path) -> Result<TableDef, String> {
150 let table = load_model_file(path).map_err(|e| format!("Failed to {e:#}"))?;
154 table
155 .normalize()
156 .map_err(|e| format!("Failed to normalize table '{}': {}", table.name, e))
157}
158
159pub fn load_models_at_compile_time() -> Result<Vec<TableDef>, Box<dyn std::error::Error>> {
161 load_models_from_dir(None)
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use crate::test_support::{CwdGuard, write_default_config};
168 use serial_test::serial;
169 use std::fs;
170 use tempfile::tempdir;
171 use vespertide_core::{
172 ColumnDef, ColumnType, SimpleColumnType, TableConstraint,
173 schema::foreign_key::ForeignKeySyntax,
174 };
175
176 #[test]
177 #[serial]
178 fn load_models_returns_empty_when_no_models_dir() {
179 let tmp = tempdir().unwrap();
180 let _guard = CwdGuard::new(tmp.path());
181 write_default_config("vespertide.json");
182
183 let models = load_models(&VespertideConfig::default()).unwrap();
185 assert_eq!(models.len(), 0);
186 }
187
188 #[test]
189 #[serial]
190 fn load_models_reads_yaml_and_validates() {
191 let tmp = tempdir().unwrap();
192 let _guard = CwdGuard::new(tmp.path());
193 write_default_config("vespertide.json");
194
195 fs::create_dir_all("models").unwrap();
196 let table = TableDef {
197 name: "users".into(),
198 description: None,
199 columns: vec![ColumnDef::new(
200 "id",
201 ColumnType::Simple(SimpleColumnType::Integer),
202 false,
203 )],
204 constraints: vec![TableConstraint::PrimaryKey {
205 auto_increment: false,
206 columns: vec!["id".into()],
207 strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(),
208 }],
209 };
210 fs::write("models/users.yaml", serde_yaml::to_string(&table).unwrap()).unwrap();
211
212 let models = load_models(&VespertideConfig::default()).unwrap();
213 assert_eq!(models.len(), 1);
214 assert_eq!(models[0].name, "users");
215 }
216
217 #[cfg(unix)]
228 #[test]
229 #[serial]
230 fn load_models_follows_file_symlinks_and_skips_dangling_ones() {
231 use std::os::unix::fs::symlink;
232
233 let tmp = tempdir().unwrap();
234 let _guard = CwdGuard::new(tmp.path());
235 write_default_config("vespertide.json");
236
237 fs::create_dir_all("models").unwrap();
238 fs::create_dir_all("external").unwrap();
239
240 let write_model = |path: &str, name: &str| {
241 let table = TableDef {
242 name: name.into(),
243 description: None,
244 columns: vec![ColumnDef::new(
245 "id",
246 ColumnType::Simple(SimpleColumnType::Integer),
247 false,
248 )],
249 constraints: vec![TableConstraint::PrimaryKey {
250 auto_increment: false,
251 columns: vec!["id".into()],
252 strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(),
253 }],
254 };
255 fs::write(path, serde_json::to_string(&table).unwrap()).unwrap();
256 };
257
258 write_model("models/user.json", "users");
259 write_model("external/order.json", "orders");
260
261 symlink(tmp.path().join("external/order.json"), "models/order.json").unwrap();
262 symlink(
263 tmp.path().join("external/nowhere.json"),
264 "models/nowhere.json",
265 )
266 .unwrap();
267
268 let mut names: Vec<String> = load_models(&VespertideConfig::default())
269 .unwrap()
270 .into_iter()
271 .map(|t| t.name.into_inner())
272 .collect();
273 names.sort();
274
275 assert_eq!(names, ["orders", "users"]);
276 }
277
278 #[test]
279 #[serial]
280 fn load_models_recursive_processes_subdirectories() {
281 let tmp = tempdir().unwrap();
282 let _guard = CwdGuard::new(tmp.path());
283 write_default_config("vespertide.json");
284
285 fs::create_dir_all("models/subdir").unwrap();
286
287 let table = TableDef {
289 name: "subtable".into(),
290 description: None,
291 columns: vec![ColumnDef::new(
292 "id",
293 ColumnType::Simple(SimpleColumnType::Integer),
294 false,
295 )],
296 constraints: vec![TableConstraint::PrimaryKey {
297 auto_increment: false,
298 columns: vec!["id".into()],
299 strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(),
300 }],
301 };
302 let content = serde_json::to_string_pretty(&table).unwrap();
303 fs::write("models/subdir/subtable.json", content).unwrap();
304
305 let models = load_models(&VespertideConfig::default()).unwrap();
306 assert_eq!(models.len(), 1);
307 assert_eq!(models[0].name, "subtable");
308 }
309
310 #[test]
311 #[serial]
312 fn load_models_fails_on_invalid_fk_format() {
313 let tmp = tempdir().unwrap();
314 let _guard = CwdGuard::new(tmp.path());
315 write_default_config("vespertide.json");
316
317 fs::create_dir_all("models").unwrap();
318
319 let table = TableDef {
321 name: "orders".into(),
322 description: None,
323 columns: vec![
324 ColumnDef::new(
326 "user_id",
327 ColumnType::Simple(SimpleColumnType::Integer),
328 false,
329 )
330 .foreign_key(ForeignKeySyntax::String("invalid_format".into())),
331 ],
332 constraints: vec![],
333 };
334 fs::write(
335 "models/orders.json",
336 serde_json::to_string_pretty(&table).unwrap(),
337 )
338 .unwrap();
339
340 let result = load_models(&VespertideConfig::default());
341 assert!(result.is_err());
342 let err_msg = result.unwrap_err().to_string();
343 assert!(err_msg.contains("Failed to normalize table 'orders'"));
344 }
345
346 #[test]
351 #[serial]
352 fn load_models_ignores_non_model_extension_files() {
353 let tmp = tempdir().unwrap();
354 let _guard = CwdGuard::new(tmp.path());
355 write_default_config("vespertide.json");
356 fs::create_dir_all("models").unwrap();
357 fs::write("models/README.txt", "not a model: {{{ invalid").unwrap();
358
359 let models = load_models(&VespertideConfig::default()).unwrap();
360 assert_eq!(models.len(), 0, "the .txt file must be skipped");
361 }
362
363 #[test]
364 #[serial]
365 fn load_models_from_dir_ignores_non_model_extension_files() {
366 let temp_dir = tempdir().unwrap();
367 let models_dir = temp_dir.path().join("models");
368 fs::create_dir_all(&models_dir).unwrap();
369 fs::write(models_dir.join("notes.txt"), "not a model: {{{ invalid").unwrap();
370
371 let result = load_models_from_dir(Some(temp_dir.path().to_path_buf()));
372 assert!(
373 result.is_ok(),
374 "the .txt file must be skipped, not parsed: {result:?}"
375 );
376 assert_eq!(result.unwrap().len(), 0);
377 }
378
379 #[test]
380 #[serial]
381 fn test_load_models_from_dir_with_root() {
382 let temp_dir = tempdir().unwrap();
383 let models_dir = temp_dir.path().join("models");
384 fs::create_dir_all(&models_dir).unwrap();
385
386 let table = TableDef {
387 name: "users".into(),
388 description: None,
389 columns: vec![ColumnDef::new(
390 "id",
391 ColumnType::Simple(SimpleColumnType::Integer),
392 false,
393 )],
394 constraints: vec![],
395 };
396 fs::write(
397 models_dir.join("users.json"),
398 serde_json::to_string_pretty(&table).unwrap(),
399 )
400 .unwrap();
401
402 let result = load_models_from_dir(Some(temp_dir.path().to_path_buf()));
403 assert!(result.is_ok());
404 let models = result.unwrap();
405 assert_eq!(models.len(), 1);
406 assert_eq!(models[0].name, "users");
407 }
408
409 #[test]
410 #[serial]
411 fn test_load_models_from_dir_without_root() {
412 use std::env;
413
414 let original = env::var("CARGO_MANIFEST_DIR").ok();
416
417 unsafe {
419 env::remove_var("CARGO_MANIFEST_DIR");
420 }
421
422 let result = load_models_from_dir(None);
423 assert!(result.is_err());
424 let err_msg = result.unwrap_err().to_string();
425 assert!(err_msg.contains("CARGO_MANIFEST_DIR environment variable not set"));
426
427 if let Some(val) = original {
429 unsafe {
430 env::set_var("CARGO_MANIFEST_DIR", val);
431 }
432 }
433 }
434
435 #[test]
436 #[serial]
437 fn test_load_models_from_dir_no_models_dir() {
438 let temp_dir = tempdir().unwrap();
439 let result = load_models_from_dir(Some(temp_dir.path().to_path_buf()));
442 assert!(result.is_ok());
443 let models = result.unwrap();
444 assert_eq!(models.len(), 0);
445 }
446
447 #[test]
448 #[serial]
449 fn test_load_models_from_dir_with_yaml() {
450 let temp_dir = tempdir().unwrap();
451 let models_dir = temp_dir.path().join("models");
452 fs::create_dir_all(&models_dir).unwrap();
453
454 let table = TableDef {
455 name: "users".into(),
456 description: None,
457 columns: vec![ColumnDef::new(
458 "id",
459 ColumnType::Simple(SimpleColumnType::Integer),
460 false,
461 )],
462 constraints: vec![],
463 };
464 fs::write(
465 models_dir.join("users.yaml"),
466 serde_yaml::to_string(&table).unwrap(),
467 )
468 .unwrap();
469
470 let result = load_models_from_dir(Some(temp_dir.path().to_path_buf()));
471 assert!(result.is_ok());
472 let models = result.unwrap();
473 assert_eq!(models.len(), 1);
474 assert_eq!(models[0].name, "users");
475 }
476
477 #[test]
478 #[serial]
479 fn test_load_models_from_dir_with_yml() {
480 let temp_dir = tempdir().unwrap();
481 let models_dir = temp_dir.path().join("models");
482 fs::create_dir_all(&models_dir).unwrap();
483
484 let table = TableDef {
485 name: "users".into(),
486 description: None,
487 columns: vec![ColumnDef::new(
488 "id",
489 ColumnType::Simple(SimpleColumnType::Integer),
490 false,
491 )],
492 constraints: vec![],
493 };
494 fs::write(
495 models_dir.join("users.yml"),
496 serde_yaml::to_string(&table).unwrap(),
497 )
498 .unwrap();
499
500 let result = load_models_from_dir(Some(temp_dir.path().to_path_buf()));
501 assert!(result.is_ok());
502 let models = result.unwrap();
503 assert_eq!(models.len(), 1);
504 assert_eq!(models[0].name, "users");
505 }
506
507 #[test]
508 #[serial]
509 fn test_load_models_from_dir_recursive() {
510 let temp_dir = tempdir().unwrap();
511 let models_dir = temp_dir.path().join("models");
512 let subdir = models_dir.join("subdir");
513 fs::create_dir_all(&subdir).unwrap();
514
515 let table = TableDef {
516 name: "subtable".into(),
517 description: None,
518 columns: vec![ColumnDef::new(
519 "id",
520 ColumnType::Simple(SimpleColumnType::Integer),
521 false,
522 )],
523 constraints: vec![],
524 };
525 fs::write(
526 subdir.join("subtable.json"),
527 serde_json::to_string_pretty(&table).unwrap(),
528 )
529 .unwrap();
530
531 let result = load_models_from_dir(Some(temp_dir.path().to_path_buf()));
532 assert!(result.is_ok());
533 let models = result.unwrap();
534 assert_eq!(models.len(), 1);
535 assert_eq!(models[0].name, "subtable");
536 }
537
538 #[test]
539 #[serial]
540 fn test_load_models_from_dir_with_invalid_json() {
541 let temp_dir = tempdir().unwrap();
542 let models_dir = temp_dir.path().join("models");
543 fs::create_dir_all(&models_dir).unwrap();
544
545 fs::write(models_dir.join("invalid.json"), r#"{"invalid": json}"#).unwrap();
546
547 let result = load_models_from_dir(Some(temp_dir.path().to_path_buf()));
548 assert!(result.is_err());
549 let err_msg = result.unwrap_err().to_string();
550 assert!(err_msg.contains("Failed to parse JSON model"));
551 }
552
553 #[test]
554 #[serial]
555 fn test_load_models_from_dir_with_invalid_yaml() {
556 let temp_dir = tempdir().unwrap();
557 let models_dir = temp_dir.path().join("models");
558 fs::create_dir_all(&models_dir).unwrap();
559
560 fs::write(models_dir.join("invalid.yaml"), r"invalid: [yaml").unwrap();
561
562 let result = load_models_from_dir(Some(temp_dir.path().to_path_buf()));
563 assert!(result.is_err());
564 let err_msg = result.unwrap_err().to_string();
565 assert!(err_msg.contains("Failed to parse YAML model"));
566 }
567
568 #[test]
569 #[serial]
570 fn test_load_models_from_dir_normalization_error() {
571 let temp_dir = tempdir().unwrap();
572 let models_dir = temp_dir.path().join("models");
573 fs::create_dir_all(&models_dir).unwrap();
574
575 let table = TableDef {
577 name: "orders".into(),
578 description: None,
579 columns: vec![
580 ColumnDef::new(
581 "user_id",
582 ColumnType::Simple(SimpleColumnType::Integer),
583 false,
584 )
585 .foreign_key(ForeignKeySyntax::String("invalid_format".into())),
586 ],
587 constraints: vec![],
588 };
589 fs::write(
590 models_dir.join("orders.json"),
591 serde_json::to_string_pretty(&table).unwrap(),
592 )
593 .unwrap();
594
595 let result = load_models_from_dir(Some(temp_dir.path().to_path_buf()));
596 assert!(result.is_err());
597 let err_msg = result.unwrap_err().to_string();
598 assert!(err_msg.contains("Failed to normalize table 'orders'"));
599 }
600
601 #[test]
602 #[serial]
603 fn test_load_models_from_dir_with_cargo_manifest_dir() {
604 let result = load_models_from_dir(None);
607 let _ = result;
611 }
612
613 #[test]
614 #[serial]
615 fn test_load_models_at_compile_time() {
616 let result = load_models_at_compile_time();
620 let _ = result;
624 }
625}