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
18use std::collections::HashMap;
19use std::path::PathBuf;
20
21mod codegen;
22
23pub use codegen::parser::ParsedTraitDef;
24
25/// Default schema file suffix scanned by [`build`] / [`generate_models`].
26pub const DEFAULT_SCHEMA_SUFFIX: &str = "_valence_schema.rs";
27
28/// Default trait file suffix scanned by [`build`] / [`generate_models`].
29pub const DEFAULT_TRAIT_SUFFIX: &str = "_valence_trait.rs";
30
31/// Default schemas subdirectory under `CARGO_MANIFEST_DIR`.
32pub const DEFAULT_SCHEMAS_SUBDIR: &str = "schemas";
33
34/// Overrides for [`build_with`].
35///
36/// Most hosts call [`build`] and leave these at [`BuildOptions::default`].
37pub struct BuildOptions<'a> {
38    /// Subdirectory under the manifest dir that holds schema/trait files.
39    pub schemas_subdir: &'a str,
40    /// Schema file suffix (default [`DEFAULT_SCHEMA_SUFFIX`]).
41    pub file_suffix: &'a str,
42    /// Trait file suffix (default [`DEFAULT_TRAIT_SUFFIX`]).
43    pub trait_file_suffix: &'a str,
44    /// Override `CARGO_MANIFEST_DIR` (tests and custom roots).
45    pub manifest_dir: Option<PathBuf>,
46    /// Override `OUT_DIR` (tests and custom roots).
47    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
62/// Scan `$CARGO_MANIFEST_DIR/schemas` and write `$OUT_DIR/generated_models.rs`.
63///
64/// # Examples
65///
66/// ```ignore
67/// // build.rs
68/// fn main() {
69///     valence_codegen::build().expect("valence codegen failed");
70/// }
71/// ```
72pub fn build() -> anyhow::Result<()> {
73    build_with(BuildOptions::default())
74}
75
76/// Like [`build`], with path/suffix overrides.
77///
78/// # Examples
79///
80/// ```ignore
81/// use valence_codegen::{build_with, BuildOptions};
82///
83/// build_with(BuildOptions {
84///     schemas_subdir: "my_schemas",
85///     ..BuildOptions::default()
86/// })?;
87/// # Ok::<(), anyhow::Error>(())
88/// ```
89pub 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
114/// Configuration for model code generation from schema files
115pub struct CodegenConfig<'a> {
116    /// Directory containing schema files (files ending with `file_suffix`)
117    pub schemas_dir: PathBuf,
118    /// Output directory where generated_models.rs will be written
119    pub out_dir: PathBuf,
120    /// File suffix to match (e.g., "_valence_schema.rs")
121    pub file_suffix: &'a str,
122    /// File suffix for trait files (e.g., "_valence_trait.rs")
123    pub trait_file_suffix: &'a str,
124}
125
126/// Generate model code from schema files in the configured directory
127///
128/// Two-pass pipeline:
129///   Pass 1 – scan `*_valence_trait.rs` files, build a trait-definitions map.
130///   Pass 2 – process `*_valence_schema.rs` files with trait context so that
131///            trait fields/connections can be merged and trait impls generated.
132pub fn generate_models(config: CodegenConfig<'_>) -> anyhow::Result<()> {
133    validate_schemas_dir(&config.schemas_dir)?;
134
135    // Pass 1: collect + generate trait definitions
136    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    // Pass 2: collect + generate schema code (with trait context)
140    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
144    write_generated_code(&config.out_dir, &generated_code)
145}
146
147/// Ensure the configured schemas directory exists before scanning.
148fn 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
155/// Collect `*.rs` paths under `dir` whose file stem ends with `suffix` (e.g. `_valence_schema`).
156fn 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
180/// Parse all trait schema files into a map keyed by trait name.
181fn 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
200/// Concatenate formatted Rust source for all traits then all schemas (trait order first for type visibility).
201fn 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    // Header
209    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    // Emit trait definitions first (types must exist before schema impls)
218    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    // Emit schema code
234    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
254/// Write `generated_models.rs` into the configured output directory (typically `OUT_DIR`).
255fn 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}