use std::collections::HashMap;
use std::path::PathBuf;
mod codegen;
pub use codegen::parser::ParsedTraitDef;
pub const DEFAULT_SCHEMA_SUFFIX: &str = "_valence_schema.rs";
pub const DEFAULT_TRAIT_SUFFIX: &str = "_valence_trait.rs";
pub const DEFAULT_SCHEMAS_SUBDIR: &str = "schemas";
pub struct BuildOptions<'a> {
pub schemas_subdir: &'a str,
pub file_suffix: &'a str,
pub trait_file_suffix: &'a str,
pub manifest_dir: Option<PathBuf>,
pub out_dir: Option<PathBuf>,
}
impl Default for BuildOptions<'static> {
fn default() -> Self {
Self {
schemas_subdir: DEFAULT_SCHEMAS_SUBDIR,
file_suffix: DEFAULT_SCHEMA_SUFFIX,
trait_file_suffix: DEFAULT_TRAIT_SUFFIX,
manifest_dir: None,
out_dir: None,
}
}
}
pub fn build() -> anyhow::Result<()> {
build_with(BuildOptions::default())
}
pub fn build_with(options: BuildOptions<'_>) -> anyhow::Result<()> {
let manifest_dir = match options.manifest_dir {
Some(path) => path,
None => PathBuf::from(
std::env::var("CARGO_MANIFEST_DIR")
.map_err(|_| anyhow::anyhow!("CARGO_MANIFEST_DIR is not set"))?,
),
};
let out_dir = match options.out_dir {
Some(path) => path,
None => PathBuf::from(
std::env::var("OUT_DIR").map_err(|_| anyhow::anyhow!("OUT_DIR is not set"))?,
),
};
let schemas_dir = manifest_dir.join(options.schemas_subdir);
println!("cargo:rerun-if-changed={}", schemas_dir.display());
generate_models(CodegenConfig {
schemas_dir,
out_dir,
file_suffix: options.file_suffix,
trait_file_suffix: options.trait_file_suffix,
})
}
pub struct CodegenConfig<'a> {
pub schemas_dir: PathBuf,
pub out_dir: PathBuf,
pub file_suffix: &'a str,
pub trait_file_suffix: &'a str,
}
pub fn generate_models(config: CodegenConfig<'_>) -> anyhow::Result<()> {
validate_schemas_dir(&config.schemas_dir)?;
let trait_files = collect_files_with_suffix(&config.schemas_dir, config.trait_file_suffix)?;
let trait_defs = build_trait_definitions(&trait_files)?;
let schema_files = collect_files_with_suffix(&config.schemas_dir, config.file_suffix)?;
let generated_code = build_generated_code(&schema_files, &trait_files, &trait_defs);
write_generated_code(&config.out_dir, &generated_code)
}
fn validate_schemas_dir(path: &PathBuf) -> anyhow::Result<()> {
if !path.exists() {
anyhow::bail!("Schemas directory does not exist: {}", path.display());
}
Ok(())
}
fn collect_files_with_suffix(dir: &PathBuf, suffix: &str) -> anyhow::Result<Vec<PathBuf>> {
use std::fs;
let stem_suffix = suffix.strip_suffix(".rs").unwrap_or(suffix);
let entries =
fs::read_dir(dir).map_err(|e| anyhow::anyhow!("Failed to read schemas directory: {e}"))?;
let mut paths = Vec::new();
for entry in entries.filter_map(|entry| entry.ok()) {
let path = entry.path();
let matches_suffix = path
.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|s| s.ends_with(stem_suffix));
let is_rs = path.extension().is_some_and(|ext| ext == "rs");
if matches_suffix && is_rs {
paths.push(path);
}
}
Ok(paths)
}
fn build_trait_definitions(
trait_files: &[PathBuf],
) -> anyhow::Result<HashMap<String, ParsedTraitDef>> {
use std::fs;
let mut map = HashMap::new();
for path in trait_files {
println!("cargo:rerun-if-changed={}", path.display());
let content = fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read trait file {}: {e}", path.display()))?;
let def = codegen::parser::extract_trait_from_file(&content)
.map_err(|e| anyhow::anyhow!("Failed to parse trait file {}: {e}", path.display()))?;
map.insert(def.name.clone(), def);
}
Ok(map)
}
fn build_generated_code(
schema_files: &[PathBuf],
trait_files: &[PathBuf],
trait_defs: &HashMap<String, ParsedTraitDef>,
) -> String {
let mut generated_code = String::new();
generated_code.push_str("// This file is auto-generated by build.rs\n");
generated_code.push_str("// DO NOT EDIT MANUALLY\n\n");
generated_code.push_str("#[allow(dead_code)]\n");
generated_code.push_str("#[allow(clippy::uninlined_format_args)]\n");
generated_code.push_str("#[allow(clippy::single_match_else)]\n");
generated_code.push_str("#[allow(clippy::unnecessary_trailing_comma)]\n");
generated_code.push_str("#[allow(clippy::unused_async)]\n\n");
for path in trait_files {
match codegen::generate_from_trait_file(path) {
Ok(code) => {
generated_code.push_str(&code);
generated_code.push_str("\n\n");
}
Err(e) => {
eprintln!(
"cargo:warning=Failed to generate trait code for {}: {e}",
path.display()
);
}
}
}
for path in schema_files {
println!("cargo:rerun-if-changed={}", path.display());
match codegen::generate_from_schema_file(path, trait_defs) {
Ok(code) => {
generated_code.push_str(&code);
generated_code.push_str("\n\n");
}
Err(e) => {
eprintln!(
"cargo:warning=Failed to generate code for {}: {e}",
path.display()
);
}
}
}
generated_code
}
fn write_generated_code(out_dir: &PathBuf, generated_code: &str) -> anyhow::Result<()> {
use std::fs;
let dest_path = out_dir.join("generated_models.rs");
fs::write(&dest_path, generated_code)
.map_err(|e| anyhow::anyhow!("Failed to write generated code: {e}"))?;
Ok(())
}
#[cfg(test)]
mod build_defaults_tests {
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use super::{
build, build_with, BuildOptions, DEFAULT_SCHEMAS_SUBDIR, DEFAULT_SCHEMA_SUFFIX,
DEFAULT_TRAIT_SUFFIX,
};
static ENV_LOCK: Mutex<()> = Mutex::new(());
static TEMP_SEQ: AtomicU64 = AtomicU64::new(0);
const MINIMAL_SCHEMA: &str = r#"
valence_schema! {
Widget {
table: "widget",
version: "0.1.0",
fields: [
id: { r#type: FieldType::String, primary_key: true, required: true },
name: { r#type: FieldType::String, required: true },
],
}
}
"#;
struct TempRoot {
path: PathBuf,
}
impl TempRoot {
fn new(label: &str) -> Self {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let seq = TEMP_SEQ.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("valence-codegen-{label}-{nanos}-{seq}"));
fs::create_dir_all(&path).expect("create temp root");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempRoot {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn restore_env(key: &str, value: Option<std::ffi::OsString>) {
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
#[test]
fn build_with_writes_models_for_valid_schema() {
let root = TempRoot::new("valid");
let schemas = root.path().join("schemas");
fs::create_dir_all(&schemas).expect("schemas dir");
fs::write(schemas.join("widget_valence_schema.rs"), MINIMAL_SCHEMA).expect("schema");
let out = root.path().join("out");
fs::create_dir_all(&out).expect("out dir");
build_with(BuildOptions {
manifest_dir: Some(root.path().to_path_buf()),
out_dir: Some(out.clone()),
..BuildOptions::default()
})
.expect("codegen should succeed");
let generated = fs::read_to_string(out.join("generated_models.rs")).expect("read");
assert!(generated.contains("Widget") || generated.contains("widget"));
assert!(generated.contains("impl") && generated.contains("Model"));
}
#[test]
fn build_with_empty_schemas_dir_writes_header_only() {
let root = TempRoot::new("empty");
fs::create_dir_all(root.path().join("schemas")).expect("schemas dir");
let out = root.path().join("out");
fs::create_dir_all(&out).expect("out dir");
build_with(BuildOptions {
manifest_dir: Some(root.path().to_path_buf()),
out_dir: Some(out.clone()),
..BuildOptions::default()
})
.expect("empty schemas dir should succeed");
let generated = fs::read_to_string(out.join("generated_models.rs")).expect("read");
assert!(generated.contains("auto-generated"));
}
#[test]
fn build_with_missing_schemas_dir_errors() {
let root = TempRoot::new("missing");
let out = root.path().join("out");
fs::create_dir_all(&out).expect("out dir");
let err = build_with(BuildOptions {
manifest_dir: Some(root.path().to_path_buf()),
out_dir: Some(out),
..BuildOptions::default()
})
.expect_err("missing schemas dir should fail");
assert!(format!("{err:#}").contains("Schemas directory does not exist"));
}
#[test]
fn build_errors_when_manifest_dir_unset() {
let _guard = ENV_LOCK.lock().expect("env lock");
let saved_manifest = std::env::var_os("CARGO_MANIFEST_DIR");
let saved_out = std::env::var_os("OUT_DIR");
std::env::remove_var("CARGO_MANIFEST_DIR");
std::env::set_var("OUT_DIR", "/tmp/valence-codegen-test-out");
let err = build().expect_err("missing CARGO_MANIFEST_DIR should fail");
assert!(format!("{err:#}").contains("CARGO_MANIFEST_DIR is not set"));
restore_env("CARGO_MANIFEST_DIR", saved_manifest);
restore_env("OUT_DIR", saved_out);
}
#[test]
fn build_errors_when_out_dir_unset() {
let _guard = ENV_LOCK.lock().expect("env lock");
let saved_manifest = std::env::var_os("CARGO_MANIFEST_DIR");
let saved_out = std::env::var_os("OUT_DIR");
std::env::set_var("CARGO_MANIFEST_DIR", "/tmp/valence-codegen-test-manifest");
std::env::remove_var("OUT_DIR");
let err = build().expect_err("missing OUT_DIR should fail");
assert!(format!("{err:#}").contains("OUT_DIR is not set"));
restore_env("CARGO_MANIFEST_DIR", saved_manifest);
restore_env("OUT_DIR", saved_out);
}
#[test]
fn build_reads_env_and_writes_models() {
let _guard = ENV_LOCK.lock().expect("env lock");
let root = TempRoot::new("env");
let schemas = root.path().join("schemas");
fs::create_dir_all(&schemas).expect("schemas dir");
fs::write(schemas.join("widget_valence_schema.rs"), MINIMAL_SCHEMA).expect("schema");
let out = root.path().join("out");
fs::create_dir_all(&out).expect("out dir");
let saved_manifest = std::env::var_os("CARGO_MANIFEST_DIR");
let saved_out = std::env::var_os("OUT_DIR");
std::env::set_var("CARGO_MANIFEST_DIR", root.path());
std::env::set_var("OUT_DIR", &out);
build().expect("build from env should succeed");
let generated = fs::read_to_string(out.join("generated_models.rs")).expect("read");
assert!(generated.contains("Widget") || generated.contains("widget"));
restore_env("CARGO_MANIFEST_DIR", saved_manifest);
restore_env("OUT_DIR", saved_out);
}
#[test]
fn cli_default_suffix_constants_match_build_options() {
let defaults = BuildOptions::default();
assert_eq!(defaults.file_suffix, DEFAULT_SCHEMA_SUFFIX);
assert_eq!(defaults.trait_file_suffix, DEFAULT_TRAIT_SUFFIX);
assert_eq!(defaults.schemas_subdir, DEFAULT_SCHEMAS_SUBDIR);
}
}