use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use crate::codegen::{GenerateError, GeneratedModuleSet, Generator};
use crate::config::GenerationConfig;
use crate::schema::Schema;
use crate::xml::{ParseError, parse, parse_file};
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum BuildError {
#[error(transparent)]
#[diagnostic(transparent)]
Parse(#[from] ParseError),
#[error(transparent)]
Generate(#[from] GenerateError),
#[error("OUT_DIR is not set (run from a Cargo build.rs script)")]
MissingOutDir,
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("schema generated no modules")]
Empty,
}
pub fn generate_to_out_dir(
schema_path: impl AsRef<Path>,
config: GenerationConfig,
) -> Result<GeneratedModuleSet, BuildError> {
generate_to_dir(schema_path, config, &out_dir()?)
}
pub fn generate_to_dir(
schema_path: impl AsRef<Path>,
config: GenerationConfig,
out_dir: impl AsRef<Path>,
) -> Result<GeneratedModuleSet, BuildError> {
let schema_path = schema_path.as_ref();
let out_dir = out_dir.as_ref();
fs::create_dir_all(out_dir)?;
let ir = parse_file(schema_path)?;
let modules = write_generated(Schema::from_ir(ir), config, out_dir)?;
println!("cargo::rerun-if-changed={}", schema_path.display());
let is_cargo_out = env::var_os("OUT_DIR")
.map(|od| out_dir.starts_with(Path::new(&od)))
.unwrap_or(false);
if !is_cargo_out {
println!(
"cargo::warning=ergo-sbe wrote {} module(s) under {} (open for go-to-definition)",
modules.modules().len(),
out_dir.display()
);
}
Ok(modules)
}
pub fn generate_str_to_out_dir(
schema_xml: &str,
config: GenerationConfig,
) -> Result<GeneratedModuleSet, BuildError> {
generate_str_to_dir(schema_xml, config, &out_dir()?)
}
pub fn generate_str_to_dir(
schema_xml: &str,
config: GenerationConfig,
out_dir: &Path,
) -> Result<GeneratedModuleSet, BuildError> {
let ir = parse(schema_xml)?;
write_generated(Schema::from_ir(ir), config, out_dir)
}
pub fn out_dir() -> Result<PathBuf, BuildError> {
env::var_os("OUT_DIR")
.map(PathBuf::from)
.ok_or(BuildError::MissingOutDir)
}
fn write_generated(
schema: Schema,
config: GenerationConfig,
out: &Path,
) -> Result<GeneratedModuleSet, BuildError> {
let modules = Generator::new(config).generate(&schema)?;
if modules.modules().len() == 0 {
return Err(BuildError::Empty);
}
for m in modules.modules() {
let path_str = &m.path;
if path_str.contains('/') || path_str.contains('\\') || path_str.contains("..") {
return Err(BuildError::Generate(
crate::codegen::GenerateError::InvalidConfiguration {
option: "module_path".into(),
value: path_str.clone(),
reason: "module path must be a plain .rs basename — no path separators".into(),
},
));
}
let dest = out.join(&m.path);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&dest, &m.source)?;
}
for w in modules.warnings() {
println!("cargo::warning={w}");
}
Ok(modules)
}
#[macro_export]
macro_rules! include_sbe {
($module:literal) => {
include!(concat!(env!("OUT_DIR"), "/", $module, ".rs"));
};
($module:ident) => {
include!(concat!(env!("OUT_DIR"), "/", stringify!($module), ".rs"));
};
}
#[macro_export]
macro_rules! sbe_mod {
($name:ident) => {
mod $name {
#![allow(
dead_code,
unused_imports,
unused_variables,
unused_mut,
unused_assignments,
unused_must_use,
unused_comparisons,
non_camel_case_types,
non_snake_case,
unexpected_cfgs,
unused_unsafe,
clippy::all,
clippy::pedantic,
clippy::nursery,
clippy::unwrap_used,
clippy::expect_used,
clippy::panic
)]
include!(concat!(env!("OUT_DIR"), "/", stringify!($name), ".rs"));
}
};
($vis:vis $name:ident) => {
$vis mod $name {
#![allow(
dead_code,
unused_imports,
unused_variables,
unused_mut,
unused_assignments,
unused_must_use,
unused_comparisons,
unused_unsafe,
non_camel_case_types,
non_snake_case,
unexpected_cfgs,
clippy::all,
clippy::pedantic,
clippy::nursery,
clippy::unwrap_used,
clippy::expect_used,
clippy::panic
)]
include!(concat!(env!("OUT_DIR"), "/", stringify!($name), ".rs"));
}
};
($name:ident = $file:literal) => {
mod $name {
#![allow(
dead_code,
unused_imports,
unused_variables,
unused_mut,
unused_assignments,
unused_must_use,
unused_comparisons,
non_camel_case_types,
non_snake_case,
unexpected_cfgs,
clippy::all
)]
include!(concat!(env!("OUT_DIR"), "/", $file, ".rs"));
}
};
($vis:vis $name:ident = $file:literal) => {
$vis mod $name {
#![allow(
dead_code,
unused_imports,
unused_variables,
unused_mut,
unused_assignments,
unused_must_use,
unused_comparisons,
non_camel_case_types,
non_snake_case,
unexpected_cfgs,
clippy::all
)]
include!(concat!(env!("OUT_DIR"), "/", $file, ".rs"));
}
};
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_schema() -> &'static str {
r#"<?xml version="1.0"?>
<messageSchema package="t" id="1" version="0" byteOrder="littleEndian">
<types>
<composite name="messageHeader">
<type name="blockLength" primitiveType="uint16"/>
<type name="templateId" primitiveType="uint16"/>
<type name="schemaId" primitiveType="uint16"/>
<type name="version" primitiveType="uint16"/>
</composite>
</types>
<message name="Ping" id="1">
<field name="seq" id="1" type="uint32" offset="0"/>
</message>
</messageSchema>"#
}
#[test]
fn miette_is_reexported_for_build_rs_return_type() {
fn _build_rs_main() -> crate::miette::Result<()> {
Ok(())
}
}
#[test]
fn generate_str_to_dir_writes_module() -> Result<(), Box<dyn std::error::Error>> {
let dir = tempfile_dir()?;
let set = generate_str_to_dir(minimal_schema(), GenerationConfig::new("ping"), &dir)?;
assert_eq!(set.modules().len(), 1);
let path = dir.join("ping.rs");
assert!(path.is_file(), "expected {}", path.display());
let src = fs::read_to_string(&path)?;
assert!(src.contains("PingEncoder"), "{src}");
assert!(src.contains("PingDecoder"), "{src}");
let _ = fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn generate_to_dir_reads_schema_file() -> Result<(), Box<dyn std::error::Error>> {
let dir = tempfile_dir()?;
let schema_path = dir.join("messages.xml");
fs::write(&schema_path, minimal_schema())?;
let explicit = dir.join("explicit");
let set = generate_to_dir(&schema_path, GenerationConfig::new("from_file"), &explicit)?;
assert_eq!(set.modules().len(), 1);
assert!(explicit.join("from_file.rs").is_file());
fs::remove_dir_all(&dir)?;
Ok(())
}
#[test]
fn build_error_parse_variant_renders_source_snippet_via_miette()
-> Result<(), Box<dyn std::error::Error>> {
let bad_xml = r#"<messageSchema package="x" id="1" version="0">
<types><composite name="messageHeader"><type name="blockLength" primitiveType="uint16"/><type name="templateId" primitiveType="uint16"/><type name="schemaId" primitiveType="uint16"/><type name="version" primitiveType="uint16"/></composite></types>
<message name="M" id="1"><field name="f" id="1" type="bogus"/></message>
</messageSchema>"#;
let dir = tempfile_dir()?;
let err = generate_str_to_dir(bad_xml, GenerationConfig::new("bad"), &dir).unwrap_err();
let _ = fs::remove_dir_all(&dir);
assert!(
matches!(err, BuildError::Parse(_)),
"expected BuildError::Parse, got {err:?}"
);
let mut rendered = String::new();
miette::GraphicalReportHandler::new_themed(miette::GraphicalTheme::unicode_nocolor())
.render_report(&mut rendered, &err)?;
assert!(rendered.contains("bogus"), "rendered:\n{rendered}");
assert!(
rendered.lines().count() > 1,
"expected a multi-line snippet through the transparent wrapper, got:\n{rendered}"
);
Ok(())
}
fn tempfile_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
let dir = env::temp_dir().join(format!(
"ergo_sbe_build_test_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_nanos()
));
fs::create_dir_all(&dir)?;
Ok(dir)
}
}