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