1#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
19
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22
23mod codegen;
24
25pub use codegen::parser::ParsedTraitDef;
26
27pub const DEFAULT_SCHEMA_SUFFIX: &str = "_valence_schema.rs";
29
30pub const DEFAULT_TRAIT_SUFFIX: &str = "_valence_trait.rs";
32
33pub const DEFAULT_SCHEMAS_SUBDIR: &str = "schemas";
35
36pub struct BuildOptions<'a> {
40 pub schemas_subdir: &'a str,
42 pub file_suffix: &'a str,
44 pub trait_file_suffix: &'a str,
46 pub manifest_dir: Option<PathBuf>,
48 pub out_dir: Option<PathBuf>,
50}
51
52impl Default for BuildOptions<'static> {
53 fn default() -> Self {
54 Self {
55 schemas_subdir: DEFAULT_SCHEMAS_SUBDIR,
56 file_suffix: DEFAULT_SCHEMA_SUFFIX,
57 trait_file_suffix: DEFAULT_TRAIT_SUFFIX,
58 manifest_dir: None,
59 out_dir: None,
60 }
61 }
62}
63
64pub fn build() -> anyhow::Result<()> {
79 build_with(BuildOptions::default())
80}
81
82pub fn build_with(options: BuildOptions<'_>) -> anyhow::Result<()> {
100 let manifest_dir = match options.manifest_dir {
101 Some(path) => path,
102 None => PathBuf::from(
103 std::env::var("CARGO_MANIFEST_DIR")
104 .map_err(|_| anyhow::anyhow!("CARGO_MANIFEST_DIR is not set"))?,
105 ),
106 };
107 let out_dir = match options.out_dir {
108 Some(path) => path,
109 None => PathBuf::from(
110 std::env::var("OUT_DIR").map_err(|_| anyhow::anyhow!("OUT_DIR is not set"))?,
111 ),
112 };
113 let schemas_dir = manifest_dir.join(options.schemas_subdir);
114 #[allow(clippy::print_stdout)] {
116 println!("cargo:rerun-if-changed={}", schemas_dir.display());
117 }
118
119 generate_models(&CodegenConfig {
120 schemas_dir,
121 out_dir,
122 file_suffix: options.file_suffix,
123 trait_file_suffix: options.trait_file_suffix,
124 })
125}
126
127pub struct CodegenConfig<'a> {
129 pub schemas_dir: PathBuf,
131 pub out_dir: PathBuf,
133 pub file_suffix: &'a str,
135 pub trait_file_suffix: &'a str,
137}
138
139pub fn generate_models(config: &CodegenConfig<'_>) -> anyhow::Result<()> {
150 validate_schemas_dir(&config.schemas_dir)?;
151
152 let trait_files = collect_files_with_suffix(&config.schemas_dir, config.trait_file_suffix)?;
154 let trait_defs = build_trait_definitions(&trait_files)?;
155
156 let schema_files = collect_files_with_suffix(&config.schemas_dir, config.file_suffix)?;
158 let generated_code = build_generated_code(&schema_files, &trait_files, &trait_defs);
159
160 write_generated_code(&config.out_dir, &generated_code)
162}
163
164fn validate_schemas_dir(path: &Path) -> anyhow::Result<()> {
166 if !path.exists() {
167 anyhow::bail!("Schemas directory does not exist: {}", path.display());
168 }
169 Ok(())
170}
171
172fn collect_files_with_suffix(dir: &Path, suffix: &str) -> anyhow::Result<Vec<PathBuf>> {
174 use std::fs;
175
176 let stem_suffix = suffix.strip_suffix(".rs").unwrap_or(suffix);
177
178 let entries =
179 fs::read_dir(dir).map_err(|e| anyhow::anyhow!("Failed to read schemas directory: {e}"))?;
180
181 let mut paths = Vec::new();
182 for entry in entries.filter_map(|entry| entry.ok()) {
183 let path = entry.path();
184 let matches_suffix = path
185 .file_stem()
186 .and_then(|s| s.to_str())
187 .is_some_and(|s| s.ends_with(stem_suffix));
188 let is_rs = path.extension().is_some_and(|ext| ext == "rs");
189 if matches_suffix && is_rs {
190 paths.push(path);
191 }
192 }
193
194 Ok(paths)
195}
196
197fn build_trait_definitions(
199 trait_files: &[PathBuf],
200) -> anyhow::Result<HashMap<String, ParsedTraitDef>> {
201 use std::fs;
202
203 let mut map = HashMap::new();
204
205 for path in trait_files {
206 #[allow(clippy::print_stdout)] {
208 println!("cargo:rerun-if-changed={}", path.display());
209 }
210 let content = fs::read_to_string(path)
211 .map_err(|e| anyhow::anyhow!("Failed to read trait file {}: {e}", path.display()))?;
212 let def = codegen::parser::extract_trait_from_file(&content)
213 .map_err(|e| anyhow::anyhow!("Failed to parse trait file {}: {e}", path.display()))?;
214 map.insert(def.name.clone(), def);
215 }
216
217 Ok(map)
218}
219
220fn build_generated_code(
222 schema_files: &[PathBuf],
223 trait_files: &[PathBuf],
224 trait_defs: &HashMap<String, ParsedTraitDef>,
225) -> String {
226 let mut generated_code = String::new();
227
228 generated_code.push_str("// This file is auto-generated by build.rs\n");
230 generated_code.push_str("// DO NOT EDIT MANUALLY\n\n");
231 generated_code.push_str("#[allow(dead_code)]\n");
233 generated_code.push_str("#[allow(clippy::uninlined_format_args)]\n");
234 generated_code.push_str("#[allow(clippy::single_match_else)]\n");
235 generated_code.push_str("#[allow(clippy::unnecessary_trailing_comma)]\n");
236 generated_code.push_str("#[allow(clippy::unused_async)]\n\n");
237
238 for path in trait_files {
240 match codegen::generate_from_trait_file(path) {
241 Ok(code) => {
242 generated_code.push_str(&code);
243 generated_code.push_str("\n\n");
244 }
245 Err(e) => {
246 #[allow(clippy::print_stderr)] {
248 eprintln!(
249 "cargo:warning=Failed to generate trait code for {}: {e}",
250 path.display()
251 );
252 }
253 }
254 }
255 }
256
257 for path in schema_files {
259 #[allow(clippy::print_stdout)] {
261 println!("cargo:rerun-if-changed={}", path.display());
262 }
263
264 match codegen::generate_from_schema_file(path, trait_defs) {
265 Ok(code) => {
266 generated_code.push_str(&code);
267 generated_code.push_str("\n\n");
268 }
269 Err(e) => {
270 #[allow(clippy::print_stderr)] {
272 eprintln!(
273 "cargo:warning=Failed to generate code for {}: {e}",
274 path.display()
275 );
276 }
277 }
278 }
279 }
280
281 generated_code
282}
283
284fn write_generated_code(out_dir: &Path, generated_code: &str) -> anyhow::Result<()> {
286 use std::fs;
287
288 let dest_path = out_dir.join("generated_models.rs");
289 fs::write(&dest_path, generated_code)
290 .map_err(|e| anyhow::anyhow!("Failed to write generated code: {e}"))?;
291 Ok(())
292}
293
294#[cfg(test)]
295mod build_defaults_tests {
296 #![allow(clippy::expect_used, clippy::unwrap_used)]
297
298 use std::fs;
299 use std::path::{Path, PathBuf};
300 use std::sync::atomic::{AtomicU64, Ordering};
301 use std::sync::Mutex;
302 use std::time::{SystemTime, UNIX_EPOCH};
303
304 use super::{
305 build, build_with, BuildOptions, DEFAULT_SCHEMAS_SUBDIR, DEFAULT_SCHEMA_SUFFIX,
306 DEFAULT_TRAIT_SUFFIX,
307 };
308
309 static ENV_LOCK: Mutex<()> = Mutex::new(());
310 static TEMP_SEQ: AtomicU64 = AtomicU64::new(0);
311
312 const MINIMAL_SCHEMA: &str = r#"
313valence_schema! {
314 Widget {
315 table: "widget",
316 version: "0.1.0",
317 fields: [
318 id: { r#type: FieldType::String, primary_key: true, required: true },
319 name: { r#type: FieldType::String, required: true },
320 ],
321 }
322}
323"#;
324
325 struct TempRoot {
326 path: PathBuf,
327 }
328
329 impl TempRoot {
330 fn new(label: &str) -> Self {
331 let nanos = SystemTime::now()
332 .duration_since(UNIX_EPOCH)
333 .map_or(0, |d| d.as_nanos());
334 let seq = TEMP_SEQ.fetch_add(1, Ordering::Relaxed);
335 let path = std::env::temp_dir().join(format!("valence-codegen-{label}-{nanos}-{seq}"));
336 fs::create_dir_all(&path).expect("create temp root");
337 Self { path }
338 }
339
340 fn path(&self) -> &Path {
341 &self.path
342 }
343 }
344
345 impl Drop for TempRoot {
346 fn drop(&mut self) {
347 let _ = fs::remove_dir_all(&self.path);
348 }
349 }
350
351 fn restore_env(key: &str, value: Option<std::ffi::OsString>) {
352 match value {
353 Some(v) => std::env::set_var(key, v),
354 None => std::env::remove_var(key),
355 }
356 }
357
358 #[test]
359 fn build_with_writes_models_for_valid_schema() {
360 let root = TempRoot::new("valid");
361 let schemas = root.path().join("schemas");
362 fs::create_dir_all(&schemas).expect("schemas dir");
363 fs::write(schemas.join("widget_valence_schema.rs"), MINIMAL_SCHEMA).expect("schema");
364 let out = root.path().join("out");
365 fs::create_dir_all(&out).expect("out dir");
366
367 build_with(BuildOptions {
368 manifest_dir: Some(root.path().to_path_buf()),
369 out_dir: Some(out.clone()),
370 ..BuildOptions::default()
371 })
372 .expect("codegen should succeed");
373
374 let generated = fs::read_to_string(out.join("generated_models.rs")).expect("read");
375 assert!(generated.contains("Widget") || generated.contains("widget"));
376 assert!(generated.contains("impl") && generated.contains("Model"));
377 }
378
379 #[test]
380 fn build_with_empty_schemas_dir_writes_header_only() {
381 let root = TempRoot::new("empty");
382 fs::create_dir_all(root.path().join("schemas")).expect("schemas dir");
383 let out = root.path().join("out");
384 fs::create_dir_all(&out).expect("out dir");
385
386 build_with(BuildOptions {
387 manifest_dir: Some(root.path().to_path_buf()),
388 out_dir: Some(out.clone()),
389 ..BuildOptions::default()
390 })
391 .expect("empty schemas dir should succeed");
392
393 let generated = fs::read_to_string(out.join("generated_models.rs")).expect("read");
394 assert!(generated.contains("auto-generated"));
395 }
396
397 #[test]
398 fn build_with_missing_schemas_dir_errors() {
399 let root = TempRoot::new("missing");
400 let out = root.path().join("out");
401 fs::create_dir_all(&out).expect("out dir");
402
403 let err = build_with(BuildOptions {
404 manifest_dir: Some(root.path().to_path_buf()),
405 out_dir: Some(out),
406 ..BuildOptions::default()
407 })
408 .expect_err("missing schemas dir should fail");
409
410 assert!(format!("{err:#}").contains("Schemas directory does not exist"));
411 }
412
413 #[test]
414 fn build_errors_when_manifest_dir_unset() {
415 let _guard = ENV_LOCK.lock().expect("env lock");
416 let saved_manifest = std::env::var_os("CARGO_MANIFEST_DIR");
417 let saved_out = std::env::var_os("OUT_DIR");
418 std::env::remove_var("CARGO_MANIFEST_DIR");
419 std::env::set_var("OUT_DIR", "/tmp/valence-codegen-test-out");
420
421 let err = build().expect_err("missing CARGO_MANIFEST_DIR should fail");
422 assert!(format!("{err:#}").contains("CARGO_MANIFEST_DIR is not set"));
423
424 restore_env("CARGO_MANIFEST_DIR", saved_manifest);
425 restore_env("OUT_DIR", saved_out);
426 }
427
428 #[test]
429 fn build_errors_when_out_dir_unset() {
430 let _guard = ENV_LOCK.lock().expect("env lock");
431 let saved_manifest = std::env::var_os("CARGO_MANIFEST_DIR");
432 let saved_out = std::env::var_os("OUT_DIR");
433 std::env::set_var("CARGO_MANIFEST_DIR", "/tmp/valence-codegen-test-manifest");
434 std::env::remove_var("OUT_DIR");
435
436 let err = build().expect_err("missing OUT_DIR should fail");
437 assert!(format!("{err:#}").contains("OUT_DIR is not set"));
438
439 restore_env("CARGO_MANIFEST_DIR", saved_manifest);
440 restore_env("OUT_DIR", saved_out);
441 }
442
443 #[test]
444 fn build_reads_env_and_writes_models() {
445 let _guard = ENV_LOCK.lock().expect("env lock");
446 let root = TempRoot::new("env");
447 let schemas = root.path().join("schemas");
448 fs::create_dir_all(&schemas).expect("schemas dir");
449 fs::write(schemas.join("widget_valence_schema.rs"), MINIMAL_SCHEMA).expect("schema");
450 let out = root.path().join("out");
451 fs::create_dir_all(&out).expect("out dir");
452
453 let saved_manifest = std::env::var_os("CARGO_MANIFEST_DIR");
454 let saved_out = std::env::var_os("OUT_DIR");
455 std::env::set_var("CARGO_MANIFEST_DIR", root.path());
456 std::env::set_var("OUT_DIR", &out);
457
458 build().expect("build from env should succeed");
459 let generated = fs::read_to_string(out.join("generated_models.rs")).expect("read");
460 assert!(generated.contains("Widget") || generated.contains("widget"));
461
462 restore_env("CARGO_MANIFEST_DIR", saved_manifest);
463 restore_env("OUT_DIR", saved_out);
464 }
465
466 #[test]
467 fn cli_default_suffix_constants_match_build_options() {
468 let defaults = BuildOptions::default();
469 assert_eq!(defaults.file_suffix, DEFAULT_SCHEMA_SUFFIX);
470 assert_eq!(defaults.trait_file_suffix, DEFAULT_TRAIT_SUFFIX);
471 assert_eq!(defaults.schemas_subdir, DEFAULT_SCHEMAS_SUBDIR);
472 }
473}