Skip to main content

valence_codegen/
lib.rs

1//! Build-time code generation for Valence models from `valence_schema!` and `valence_trait_schema!` sources.
2//!
3//! # Pipeline
4//!
5//! 1. **Trait pass** — Collect `*_valence_trait.rs` (or your configured suffix), parse trait defs, emit
6//!    shared trait definitions (see `generate_from_trait_file` in the internal `codegen` module).
7//! 2. **Schema pass** — For each `*_valence_schema.rs`, merge trait fields/connections, validate, then
8//!    emit structs, connections, CRUD, query builder, metadata, and trait impls into one
9//!    `generated_models.rs` via [`generate_models`].
10//!
11//! # Where to read next
12//!
13//! - [`build`] / [`build_with`] — one-liner host `build.rs` helpers.
14//! - Internal `codegen` module — per-schema orchestration and generator dispatch.
15//! - `codegen::parser` — shared [`valence_schema_dsl`] parse + lower into generator IR.
16//! - `codegen::generators` — `proc_macro2::TokenStream` builders for each emitted surface area.
17
18#![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
27/// Default schema file suffix scanned by [`build`] / [`generate_models`].
28pub const DEFAULT_SCHEMA_SUFFIX: &str = "_valence_schema.rs";
29
30/// Default trait file suffix scanned by [`build`] / [`generate_models`].
31pub const DEFAULT_TRAIT_SUFFIX: &str = "_valence_trait.rs";
32
33/// Default schemas subdirectory under `CARGO_MANIFEST_DIR`.
34pub const DEFAULT_SCHEMAS_SUBDIR: &str = "schemas";
35
36/// Overrides for [`build_with`].
37///
38/// Most hosts call [`build`] and leave these at [`BuildOptions::default`].
39pub struct BuildOptions<'a> {
40    /// Subdirectory under the manifest dir that holds schema/trait files.
41    pub schemas_subdir: &'a str,
42    /// Schema file suffix (default [`DEFAULT_SCHEMA_SUFFIX`]).
43    pub file_suffix: &'a str,
44    /// Trait file suffix (default [`DEFAULT_TRAIT_SUFFIX`]).
45    pub trait_file_suffix: &'a str,
46    /// Override `CARGO_MANIFEST_DIR` (tests and custom roots).
47    pub manifest_dir: Option<PathBuf>,
48    /// Override `OUT_DIR` (tests and custom roots).
49    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
64/// Scan `$CARGO_MANIFEST_DIR/schemas` and write `$OUT_DIR/generated_models.rs`.
65///
66/// # Examples
67///
68/// ```ignore
69/// // build.rs
70/// fn main() {
71///     valence_codegen::build().expect("valence codegen failed");
72/// }
73/// ```
74///
75/// # Errors
76///
77/// Returns an error when env vars, schema paths, or codegen steps fail.
78pub fn build() -> anyhow::Result<()> {
79    build_with(BuildOptions::default())
80}
81
82/// Like [`build`], with path/suffix overrides.
83///
84/// # Examples
85///
86/// ```ignore
87/// use valence_codegen::{build_with, BuildOptions};
88///
89/// build_with(BuildOptions {
90///     schemas_subdir: "my_schemas",
91///     ..BuildOptions::default()
92/// })?;
93/// # Ok::<(), anyhow::Error>(())
94/// ```
95///
96/// # Errors
97///
98/// Returns an error when env vars, schema paths, or codegen steps fail.
99pub 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)] // cargo build-script protocol
115    {
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
127/// Configuration for model code generation from schema files
128pub struct CodegenConfig<'a> {
129    /// Directory containing schema files (files ending with `file_suffix`)
130    pub schemas_dir: PathBuf,
131    /// Output directory where generated_models.rs will be written
132    pub out_dir: PathBuf,
133    /// File suffix to match (e.g., "_valence_schema.rs")
134    pub file_suffix: &'a str,
135    /// File suffix for trait files (e.g., "_valence_trait.rs")
136    pub trait_file_suffix: &'a str,
137}
138
139/// Generate model code from schema files in the configured directory
140///
141/// Two-pass pipeline:
142///   Pass 1 – scan `*_valence_trait.rs` files, build a trait-definitions map.
143///   Pass 2 – process `*_valence_schema.rs` files with trait context so that
144///            trait fields/connections can be merged and trait impls generated.
145///
146/// # Errors
147///
148/// Returns an error when the schemas directory is missing or generation/write fails.
149pub fn generate_models(config: &CodegenConfig<'_>) -> anyhow::Result<()> {
150    validate_schemas_dir(&config.schemas_dir)?;
151
152    // Pass 1: collect + generate trait definitions
153    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    // Pass 2: collect + generate schema code (with trait context)
157    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
161    write_generated_code(&config.out_dir, &generated_code)
162}
163
164/// Ensure the configured schemas directory exists before scanning.
165fn 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
172/// Collect `*.rs` paths under `dir` whose file stem ends with `suffix` (e.g. `_valence_schema`).
173fn 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
197/// Parse all trait schema files into a map keyed by trait name.
198fn 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)] // cargo build-script protocol
207        {
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
220/// Concatenate formatted Rust source for all traits then all schemas (trait order first for type visibility).
221fn 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    // Header
229    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    // Outer allows apply to the first item; prefer `include_generated_models!` module allows.
232    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    // Emit trait definitions first (types must exist before schema impls)
239    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)] // cargo build-script protocol
247                {
248                    eprintln!(
249                        "cargo:warning=Failed to generate trait code for {}: {e}",
250                        path.display()
251                    );
252                }
253            }
254        }
255    }
256
257    // Emit schema code
258    for path in schema_files {
259        #[allow(clippy::print_stdout)] // cargo build-script protocol
260        {
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)] // cargo build-script protocol
271                {
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
284/// Write `generated_models.rs` into the configured output directory (typically `OUT_DIR`).
285fn 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}