Skip to main content

ergo_sbe/
build.rs

1//! Helpers for Cargo `build.rs` scripts.
2//!
3//! Prefer these over hand-rolling parse → generate → write → `rerun-if-changed`.
4//!
5//! ```rust,no_run
6//! // build.rs — ergo_sbe::miette::Result renders schema errors with a source
7//! // snippet by default; Box<dyn std::error::Error> prints a raw Debug dump.
8//! fn main() -> ergo_sbe::miette::Result<()> {
9//!     ergo_sbe::generate_to_out_dir(
10//!         "schemas/messages.xml",
11//!         ergo_sbe::GenerationConfig::new("messages"),
12//!     )?;
13//!     Ok(())
14//! }
15//! ```
16//!
17//! Then include the generated module from `lib.rs` or `main.rs`:
18//!
19//! ```text
20//! ergo_sbe::sbe_mod!(messages);
21//! ```
22
23use std::collections::HashSet;
24use std::env;
25use std::fs;
26use std::path::{Path, PathBuf};
27
28use crate::codegen::{GenerateError, GeneratedModuleSet, Generator};
29use crate::config::GenerationConfig;
30use crate::schema::Schema;
31use crate::xml::{ParseError, parse, parse_file_with_deps, parse_file_with_shared_deps};
32
33/// Errors from [`generate_to_out_dir`] / [`generate_str_to_out_dir`].
34///
35/// Implements [`miette::Diagnostic`] so `fn main() -> miette::Result<()>` in a
36/// `build.rs` renders schema parse errors with a source snippet and span
37/// instead of a raw `Debug` dump. Plain `Box<dyn std::error::Error>` prints
38/// `{:?}` on failure — use `miette::Result` to get the readable form.
39#[derive(Debug, thiserror::Error, miette::Diagnostic)]
40pub enum BuildError {
41    /// Schema XML could not be parsed or resolved.
42    #[error(transparent)]
43    #[diagnostic(transparent)]
44    Parse(#[from] ParseError),
45    /// Code generation failed (e.g. invalid conversion config).
46    #[error(transparent)]
47    Generate(#[from] GenerateError),
48    /// `OUT_DIR` is unset — this helper is meant for Cargo `build.rs` only.
49    #[error("OUT_DIR is not set (run from a Cargo build.rs script)")]
50    MissingOutDir,
51    /// Failed to write a generated file.
52    #[error("I/O error: {0}")]
53    Io(#[from] std::io::Error),
54    /// Generator produced no modules.
55    #[error("schema generated no modules")]
56    Empty,
57}
58
59/// One schema file in a multi-schema generation set.
60///
61/// `module_name` is the generated Rust module (`orders` → `orders.rs`), not
62/// derived from the file stem — so `common-types.xml` can emit `common_types.rs`.
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub struct SchemaFile<'a> {
65    /// Path to the schema XML file.
66    pub path: &'a Path,
67    /// Generated module identifier (no `.rs` suffix).
68    pub module_name: &'a str,
69}
70
71impl<'a> SchemaFile<'a> {
72    /// Construct a [`SchemaFile`].
73    #[must_use]
74    pub fn new(path: &'a Path, module_name: &'a str) -> Self {
75        Self { path, module_name }
76    }
77}
78
79/// Parse a schema **file**, generate codecs, write every module under `OUT_DIR`.
80///
81/// Also prints `cargo::rerun-if-changed=<schema_path>` and
82/// `cargo::warning=…` for non-fatal generation warnings.
83///
84/// `config.module_name` becomes `{module_name}.rs` (e.g. `"messages"` →
85/// `$OUT_DIR/messages.rs`).
86///
87/// # Errors
88///
89/// Parse, generate, missing `OUT_DIR`, or I/O failures.
90///
91/// # Example
92///
93/// ```rust,no_run
94/// fn main() -> Result<(), Box<dyn std::error::Error>> {
95///     ergo_sbe::generate_to_out_dir(
96///         "schemas/messages.xml",
97///         ergo_sbe::GenerationConfig::new("messages")
98///             .with_domain_objects(ergo_sbe::DomainVarData::Bytes),
99///     )?;
100///     Ok(())
101/// }
102/// ```
103pub fn generate_to_out_dir(
104    schema_path: impl AsRef<Path>,
105    config: GenerationConfig,
106) -> Result<GeneratedModuleSet, BuildError> {
107    generate_to_dir(schema_path, config, &out_dir()?)
108}
109
110/// Parse a schema **file**, generate codecs, write every module under `out_dir`.
111///
112/// Same as [`generate_to_out_dir`] but with an explicit output directory.
113///
114/// **Samples:** write to `src/generated/` (gitignored) so rust-analyzer / IDE
115/// go-to-definition works on real `.rs` files. Do **not** commit those files —
116/// they are large and change whenever the generator does.
117///
118/// Prints `cargo::rerun-if-changed` for the root schema and every resolved
119/// include, plus generation warnings.
120///
121/// # Errors
122///
123/// Parse, generate, or I/O failures.
124///
125/// # Example
126///
127/// ```rust,no_run
128/// // build.rs
129/// fn main() -> Result<(), Box<dyn std::error::Error>> {
130///     let out = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/generated");
131///     ergo_sbe::generate_to_dir(
132///         "schemas/feature-tour.xml",
133///         ergo_sbe::GenerationConfig::new("feature_tour"),
134///         &out,
135///     )?;
136///     Ok(())
137/// }
138///
139/// ```
140///
141/// In `src/lib.rs`, use the real path so the IDE can jump into the implementation:
142///
143/// ```text
144/// #[path = "generated/feature_tour.rs"]
145/// mod feature_tour;
146/// ```
147pub fn generate_to_dir(
148    schema_path: impl AsRef<Path>,
149    config: GenerationConfig,
150    out_dir: impl AsRef<Path>,
151) -> Result<GeneratedModuleSet, BuildError> {
152    let schema_path = schema_path.as_ref();
153    let out_dir = out_dir.as_ref();
154    fs::create_dir_all(out_dir)?;
155    let parsed = parse_file_with_deps(schema_path)?;
156    let modules = write_generated(Schema::from_ir(parsed.ir), config, out_dir)?;
157    for watched in schema_watch_paths(schema_path, &parsed.dependencies) {
158        println!("cargo::rerun-if-changed={}", watched.display());
159    }
160    // Point at stable IDE paths (e.g. src/generated). Skip for hashed OUT_DIR —
161    // that would spam every product/sample build that only uses generate_to_out_dir.
162    let is_cargo_out = env::var_os("OUT_DIR")
163        .map(|od| out_dir.starts_with(Path::new(&od)))
164        .unwrap_or(false);
165    if !is_cargo_out {
166        println!(
167            "cargo::warning=ergo-sbe wrote {} module(s) under {} (open for go-to-definition)",
168            modules.modules().len(),
169            out_dir.display()
170        );
171    }
172    Ok(modules)
173}
174
175/// Like [`generate_to_out_dir`], but from an XML string (e.g. `include_str!`).
176///
177/// Does **not** emit `rerun-if-changed` (no file path). Prefer
178/// [`generate_to_out_dir`] when the schema lives on disk so Cargo rebuilds
179/// when it changes. If you use `include_str!`, add your own
180/// `cargo::rerun-if-changed` for that path.
181///
182/// # Errors
183///
184/// Parse, generate, missing `OUT_DIR`, or I/O failures.
185pub fn generate_str_to_out_dir(
186    schema_xml: &str,
187    config: GenerationConfig,
188) -> Result<GeneratedModuleSet, BuildError> {
189    generate_str_to_dir(schema_xml, config, &out_dir()?)
190}
191
192/// Parse schema XML, generate codecs, write every module under `out_dir`.
193///
194/// Same as [`generate_str_to_out_dir`] but with an explicit output directory
195/// (useful in tests or non-Cargo drivers). Does not emit `rerun-if-changed`.
196///
197/// # Errors
198///
199/// Parse, generate, or I/O failures.
200pub fn generate_str_to_dir(
201    schema_xml: &str,
202    config: GenerationConfig,
203    out_dir: &Path,
204) -> Result<GeneratedModuleSet, BuildError> {
205    let ir = parse(schema_xml)?;
206    write_generated(Schema::from_ir(ir), config, out_dir)
207}
208
209/// Generate a shared schema plus consumers into `OUT_DIR`.
210///
211/// Parses `shared` first, then each consumer with [`crate::parse_file_with_shared`],
212/// validates the complete set, then writes. A late consumer failure leaves no
213/// files. Watches every root and resolved include.
214///
215/// # Errors
216///
217/// Parse, generate, missing `OUT_DIR`, I/O, or a `with_shared_module` name
218/// that does not match [`SchemaFile::module_name`] on `shared`.
219pub fn generate_multi_to_out_dir(
220    shared: SchemaFile<'_>,
221    consumers: &[SchemaFile<'_>],
222    config: GenerationConfig,
223) -> Result<GeneratedModuleSet, BuildError> {
224    generate_multi_to_dir(shared, consumers, config, &out_dir()?)
225}
226
227/// [`generate_multi_to_out_dir`] with an explicit output directory.
228///
229/// # Errors
230///
231/// Same as [`generate_multi_to_out_dir`] except `OUT_DIR` is not required.
232///
233/// ```rust,no_run
234/// use std::path::Path;
235/// use ergo_sbe::{GenerationConfig, SchemaFile, generate_multi_to_dir};
236///
237/// fn main() -> ergo_sbe::miette::Result<()> {
238///     let common = Path::new("schemas/common-types.xml");
239///     let orders = Path::new("schemas/orders.xml");
240///     generate_multi_to_dir(
241///         SchemaFile::new(common, "common_types"),
242///         &[SchemaFile::new(orders, "orders")],
243///         GenerationConfig::new("common_types"),
244///         Path::new("src/generated"),
245///     )?;
246///     Ok(())
247/// }
248/// ```
249pub fn generate_multi_to_dir(
250    shared: SchemaFile<'_>,
251    consumers: &[SchemaFile<'_>],
252    mut config: GenerationConfig,
253    out_dir: impl AsRef<Path>,
254) -> Result<GeneratedModuleSet, BuildError> {
255    if let Some(ref name) = config.shared_module {
256        if name != shared.module_name {
257            return Err(BuildError::Generate(GenerateError::InvalidConfiguration {
258                option: "shared_module".into(),
259                value: name.clone(),
260                reason: format!("must match shared.module_name {:?}", shared.module_name),
261            }));
262        }
263    } else {
264        config = config.with_shared_module(shared.module_name);
265    }
266
267    let shared_parsed = parse_file_with_deps(shared.path)?;
268    let shared_ir = shared_parsed.ir;
269    let mut watch = schema_watch_paths(shared.path, &shared_parsed.dependencies);
270    let mut consumer_irs = Vec::with_capacity(consumers.len());
271    for consumer in consumers {
272        let parsed = parse_file_with_shared_deps(consumer.path, &shared_ir)?;
273        watch.extend(schema_watch_paths(consumer.path, &parsed.dependencies));
274        consumer_irs.push((
275            Schema::from_ir(ir_with_shared_type_tokens(parsed.ir, &shared_ir)),
276            consumer.module_name,
277        ));
278    }
279    let shared_schema = Schema::from_ir(shared_ir);
280
281    let mut schemas: Vec<(&Schema, &str)> = Vec::with_capacity(1 + consumer_irs.len());
282    schemas.push((&shared_schema, shared.module_name));
283    for (schema, name) in &consumer_irs {
284        schemas.push((schema, name));
285    }
286
287    let modules = Generator::new(config).generate_multi(&schemas)?;
288    write_module_set(&modules, out_dir.as_ref())?;
289    let mut seen = HashSet::new();
290    for path in watch {
291        let key = path.canonicalize().unwrap_or_else(|_| path.clone());
292        if seen.insert(key) {
293            println!("cargo::rerun-if-changed={}", path.display());
294        }
295    }
296    Ok(modules)
297}
298
299/// Absolute path to Cargo's `OUT_DIR` (build scripts only).
300///
301/// # Errors
302///
303/// [`BuildError::MissingOutDir`] when not running under Cargo.
304pub fn out_dir() -> Result<PathBuf, BuildError> {
305    env::var_os("OUT_DIR")
306        .map(PathBuf::from)
307        .ok_or(BuildError::MissingOutDir)
308}
309
310/// Root schema first (the path Cargo was given), then remaining unique
311/// resolved includes in sorted order.
312pub(crate) fn schema_watch_paths(root: &Path, dependencies: &[PathBuf]) -> Vec<PathBuf> {
313    let mut seen = HashSet::new();
314    let mut out = Vec::new();
315    let mut push = |p: &Path| {
316        let key = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
317        if seen.insert(key) {
318            out.push(p.to_path_buf());
319        }
320    };
321    push(root);
322    let mut rest: Vec<&PathBuf> = dependencies.iter().collect();
323    rest.sort();
324    for path in rest {
325        push(path);
326    }
327    out
328}
329
330/// Copy shared type tokens (not messages) onto a consumer IR so codegen can
331/// resolve `headerType` and shared composites after `parse_file_with_shared`.
332fn ir_with_shared_type_tokens(mut consumer: crate::Ir, shared: &crate::Ir) -> crate::Ir {
333    let mut extra = Vec::new();
334    let mut i = 0;
335    while i < shared.tokens.len() {
336        if shared.tokens[i].signal == crate::Signal::BeginMessage {
337            while i < shared.tokens.len() && shared.tokens[i].signal != crate::Signal::EndMessage {
338                i += 1;
339            }
340            i += 1;
341            continue;
342        }
343        extra.push(shared.tokens[i].clone());
344        i += 1;
345    }
346    extra.append(&mut consumer.tokens);
347    consumer.tokens = extra;
348    consumer
349}
350
351fn write_generated(
352    schema: Schema,
353    config: GenerationConfig,
354    out: &Path,
355) -> Result<GeneratedModuleSet, BuildError> {
356    let modules = Generator::new(config).generate(&schema)?;
357    write_module_set(&modules, out)?;
358    Ok(modules)
359}
360
361fn write_module_set(modules: &GeneratedModuleSet, out: &Path) -> Result<(), BuildError> {
362    if modules.modules().len() == 0 {
363        return Err(BuildError::Empty);
364    }
365    fs::create_dir_all(out)?;
366    for m in modules.modules() {
367        // Defense in depth: reject paths with directory components.
368        // Generated module paths must be simple basenames like "car.rs".
369        let path_str = &m.path;
370        if path_str.contains('/') || path_str.contains('\\') || path_str.contains("..") {
371            return Err(BuildError::Generate(
372                crate::codegen::GenerateError::InvalidConfiguration {
373                    option: "module_path".into(),
374                    value: path_str.clone(),
375                    reason: "module path must be a plain .rs basename — no path separators".into(),
376                },
377            ));
378        }
379        let dest = out.join(&m.path);
380        if let Some(parent) = dest.parent() {
381            fs::create_dir_all(parent)?;
382        }
383        fs::write(&dest, &m.source)?;
384    }
385    for w in modules.warnings() {
386        println!("cargo::warning={w}");
387    }
388    Ok(())
389}
390
391/// Include a module written by [`generate_to_out_dir`] / [`generate_str_to_out_dir`].
392///
393/// After `generate_to_out_dir(..., GenerationConfig::new("messages"))`:
394/// `ergo_sbe::include_sbe!("messages");`
395///
396/// → [`samples/sbe-feature-tour/build.rs`](https://github.com/mimran1980/ergon/blob/main/samples/sbe-feature-tour/build.rs)
397///
398/// Expands to `include!(concat!(env!("OUT_DIR"), "/messages.rs"))`.
399#[macro_export]
400macro_rules! include_sbe {
401    ($module:literal) => {
402        include!(concat!(env!("OUT_DIR"), "/", $module, ".rs"));
403    };
404    ($module:ident) => {
405        include!(concat!(env!("OUT_DIR"), "/", stringify!($module), ".rs"));
406    };
407}
408
409/// Declare a module that includes generated SBE codecs from `OUT_DIR`.
410///
411/// Applies the usual `allow`s for generated code (snake/camel, unused, …).
412///
413/// After build.rs generates `$OUT_DIR/messages.rs`:
414/// `ergo_sbe::sbe_mod!(messages);` → `mod messages { ... include!(.../messages.rs); }`
415/// `ergo_sbe::sbe_mod!(pub codecs);` → public module `codecs` → `codecs.rs`
416/// `ergo_sbe::sbe_mod!(pub ergo_car = "car_bench");` → `car_bench.rs`
417///
418/// → [`samples/sbe-feature-tour/src/lib.rs`](https://github.com/mimran1980/ergon/blob/main/samples/sbe-feature-tour/src/lib.rs)
419#[macro_export]
420macro_rules! sbe_mod {
421    ($name:ident) => {
422        mod $name {
423            #![allow(
424                dead_code,
425                unused_imports,
426                unused_variables,
427                unused_mut,
428                unused_assignments,
429                unused_must_use,
430                unused_comparisons,
431                non_camel_case_types,
432                non_snake_case,
433                unexpected_cfgs,
434                unused_unsafe,
435                clippy::all,
436                clippy::pedantic,
437                clippy::nursery,
438                clippy::unwrap_used,
439                clippy::expect_used,
440                clippy::panic
441            )]
442            include!(concat!(env!("OUT_DIR"), "/", stringify!($name), ".rs"));
443        }
444    };
445    ($vis:vis $name:ident) => {
446        $vis mod $name {
447            #![allow(
448                dead_code,
449                unused_imports,
450                unused_variables,
451                unused_mut,
452                unused_assignments,
453                unused_must_use,
454                unused_comparisons,
455                unused_unsafe,
456                non_camel_case_types,
457                non_snake_case,
458                unexpected_cfgs,
459                clippy::all,
460                clippy::pedantic,
461                clippy::nursery,
462                clippy::unwrap_used,
463                clippy::expect_used,
464                clippy::panic
465            )]
466            include!(concat!(env!("OUT_DIR"), "/", stringify!($name), ".rs"));
467        }
468    };
469    ($name:ident = $file:literal) => {
470        mod $name {
471            #![allow(
472                dead_code,
473                unused_imports,
474                unused_variables,
475                unused_mut,
476                unused_assignments,
477                unused_must_use,
478                unused_comparisons,
479                non_camel_case_types,
480                non_snake_case,
481                unexpected_cfgs,
482                clippy::all
483            )]
484            include!(concat!(env!("OUT_DIR"), "/", $file, ".rs"));
485        }
486    };
487    ($vis:vis $name:ident = $file:literal) => {
488        $vis mod $name {
489            #![allow(
490                dead_code,
491                unused_imports,
492                unused_variables,
493                unused_mut,
494                unused_assignments,
495                unused_must_use,
496                unused_comparisons,
497                non_camel_case_types,
498                non_snake_case,
499                unexpected_cfgs,
500                clippy::all
501            )]
502            include!(concat!(env!("OUT_DIR"), "/", $file, ".rs"));
503        }
504    };
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use std::sync::atomic::{AtomicU64, Ordering};
511
512    fn minimal_schema() -> &'static str {
513        r#"<?xml version="1.0"?>
514        <messageSchema package="t" id="1" version="0" byteOrder="littleEndian">
515          <types>
516            <composite name="messageHeader">
517              <type name="blockLength" primitiveType="uint16"/>
518              <type name="templateId" primitiveType="uint16"/>
519              <type name="schemaId" primitiveType="uint16"/>
520              <type name="version" primitiveType="uint16"/>
521            </composite>
522          </types>
523          <message name="Ping" id="1">
524            <field name="seq" id="1" type="uint32" offset="0"/>
525          </message>
526        </messageSchema>"#
527    }
528
529    /// Proves `ergo_sbe::miette` is publicly re-exported and usable as a
530    /// `build.rs` return type without the caller adding a direct `miette`
531    /// dependency. If this re-export is ever removed or made private, this
532    /// fails to compile.
533    #[test]
534    fn miette_is_reexported_for_build_rs_return_type() -> crate::miette::Result<()> {
535        fn build_rs_main() -> crate::miette::Result<()> {
536            Ok(())
537        }
538        build_rs_main()
539    }
540
541    /// The `*_to_out_dir` helpers only work inside a build script. Called
542    /// anywhere else they must fail with [`BuildError::MissingOutDir`] rather
543    /// than panicking or picking an arbitrary directory — ergo-sbe has no
544    /// build script of its own, so `OUT_DIR` is genuinely unset here and the
545    /// test needs no environment mutation.
546    #[test]
547    fn out_dir_helpers_report_missing_out_dir_outside_a_build_script() {
548        assert!(
549            env::var_os("OUT_DIR").is_none(),
550            "ergo-sbe has no build script; OUT_DIR must be unset for this test"
551        );
552        assert!(matches!(out_dir(), Err(BuildError::MissingOutDir)));
553        assert!(matches!(
554            generate_str_to_out_dir(minimal_schema(), GenerationConfig::new("ping")),
555            Err(BuildError::MissingOutDir)
556        ));
557        assert!(matches!(
558            generate_to_out_dir("schemas/does-not-matter.xml", GenerationConfig::new("ping")),
559            Err(BuildError::MissingOutDir)
560        ));
561    }
562
563    #[test]
564    fn generate_str_to_dir_writes_module() -> Result<(), Box<dyn std::error::Error>> {
565        let dir = tempfile_dir()?;
566        let set = generate_str_to_dir(minimal_schema(), GenerationConfig::new("ping"), &dir)?;
567        assert_eq!(set.modules().len(), 1);
568        let path = dir.join("ping.rs");
569        assert!(path.is_file(), "expected {}", path.display());
570        let src = fs::read_to_string(&path)?;
571        assert!(src.contains("PingEncoder"), "{src}");
572        assert!(src.contains("PingDecoder"), "{src}");
573        let _ = fs::remove_dir_all(&dir);
574        Ok(())
575    }
576
577    #[test]
578    fn generate_to_dir_reads_schema_file() -> Result<(), Box<dyn std::error::Error>> {
579        let dir = tempfile_dir()?;
580        let schema_path = dir.join("messages.xml");
581        fs::write(&schema_path, minimal_schema())?;
582
583        let explicit = dir.join("explicit");
584        let set = generate_to_dir(&schema_path, GenerationConfig::new("from_file"), &explicit)?;
585        assert_eq!(set.modules().len(), 1);
586        assert!(explicit.join("from_file.rs").is_file());
587
588        fs::remove_dir_all(&dir)?;
589        Ok(())
590    }
591
592    #[test]
593    fn schema_watch_paths_are_root_then_sorted_unique_includes()
594    -> Result<(), Box<dyn std::error::Error>> {
595        let dir = tempfile_dir()?;
596        let root = dir.join("root.xml");
597        let leaf = dir.join("leaf.xml");
598        let mid = dir.join("mid.xml");
599        fs::write(&root, "root")?;
600        fs::write(&leaf, "leaf")?;
601        fs::write(&mid, "mid")?;
602        let root_canon = root.canonicalize()?;
603        let leaf_canon = leaf.canonicalize()?;
604        let mid_canon = mid.canonicalize()?;
605        let watched = schema_watch_paths(
606            &root,
607            &[
608                mid_canon.clone(),
609                leaf_canon.clone(),
610                root_canon.clone(),
611                leaf_canon.clone(),
612            ],
613        );
614        assert_eq!(watched.first(), Some(&root));
615        assert_eq!(watched.len(), 3, "{watched:?}");
616        let rest: Vec<_> = watched.iter().skip(1).cloned().collect();
617        let mut expected_rest = vec![leaf_canon, mid_canon];
618        expected_rest.sort();
619        assert_eq!(rest, expected_rest);
620        fs::remove_dir_all(&dir)?;
621        Ok(())
622    }
623
624    #[test]
625    fn generate_to_dir_rebuilds_after_include_only_edit() -> Result<(), Box<dyn std::error::Error>>
626    {
627        let dir = tempfile_dir()?;
628        let types = dir.join("types.xml");
629        fs::write(
630            &types,
631            r#"<?xml version="1.0"?>
632<types>
633  <composite name="messageHeader">
634    <type name="blockLength" primitiveType="uint16"/>
635    <type name="templateId" primitiveType="uint16"/>
636    <type name="schemaId" primitiveType="uint16"/>
637    <type name="version" primitiveType="uint16"/>
638  </composite>
639  <type name="Seq" primitiveType="uint32"/>
640</types>
641"#,
642        )?;
643        let schema_path = dir.join("root.xml");
644        fs::write(
645            &schema_path,
646            r#"<?xml version="1.0"?>
647<messageSchema package="t" id="1" version="0" byteOrder="littleEndian">
648  <include href="types.xml"/>
649  <types/>
650  <message name="Ping" id="1">
651    <field name="seq" id="1" type="Seq"/>
652  </message>
653</messageSchema>
654"#,
655        )?;
656        let parsed = crate::xml::parse_file_with_deps(&schema_path)?;
657        let watched = schema_watch_paths(&schema_path, &parsed.dependencies);
658        assert!(
659            watched.iter().any(|p| p.file_name() == types.file_name()),
660            "include must be watched: {watched:?}"
661        );
662
663        let out = dir.join("out");
664        generate_to_dir(&schema_path, GenerationConfig::new("ping"), &out)?;
665        let first = fs::read_to_string(out.join("ping.rs"))?;
666        assert!(
667            first.contains("u32") || first.contains("uint32"),
668            "first generate must encode Seq as uint32:\n{first}"
669        );
670
671        fs::write(
672            &types,
673            r#"<?xml version="1.0"?>
674<types>
675  <composite name="messageHeader">
676    <type name="blockLength" primitiveType="uint16"/>
677    <type name="templateId" primitiveType="uint16"/>
678    <type name="schemaId" primitiveType="uint16"/>
679    <type name="version" primitiveType="uint16"/>
680  </composite>
681  <type name="Seq" primitiveType="uint64"/>
682</types>
683"#,
684        )?;
685        generate_to_dir(&schema_path, GenerationConfig::new("ping"), &out)?;
686        let second = fs::read_to_string(out.join("ping.rs"))?;
687        assert!(
688            second.contains("u64") || second.contains("uint64"),
689            "include-only edit must regenerate Seq as uint64:\n{second}"
690        );
691        assert_ne!(first, second, "generated source must change");
692        fs::remove_dir_all(&dir)?;
693        Ok(())
694    }
695
696    fn write_multi_schemas(dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
697        fs::write(
698            dir.join("common-types.xml"),
699            r#"<?xml version="1.0"?>
700<messageSchema package="common" id="0" version="1" byteOrder="littleEndian">
701  <types>
702    <composite name="messageHeader">
703      <type name="blockLength" primitiveType="uint16"/>
704      <type name="templateId" primitiveType="uint16"/>
705      <type name="schemaId" primitiveType="uint16"/>
706      <type name="version" primitiveType="uint16"/>
707    </composite>
708    <composite name="Price">
709      <type name="mantissa" primitiveType="int64"/>
710      <type name="exponent" primitiveType="int8"/>
711    </composite>
712  </types>
713</messageSchema>
714"#,
715        )?;
716        fs::write(
717            dir.join("orders.xml"),
718            r#"<?xml version="1.0"?>
719<messageSchema package="orders" id="1" version="1" byteOrder="littleEndian">
720  <message name="NewOrder" id="1">
721    <field name="price" id="1" type="Price"/>
722  </message>
723</messageSchema>
724"#,
725        )?;
726        Ok(())
727    }
728
729    #[test]
730    fn generate_multi_to_dir_uses_supplied_module_names() -> Result<(), Box<dyn std::error::Error>>
731    {
732        let dir = tempfile_dir()?;
733        write_multi_schemas(&dir)?;
734        let out = dir.join("out");
735        let set = generate_multi_to_dir(
736            SchemaFile::new(&dir.join("common-types.xml"), "common_types"),
737            &[SchemaFile::new(&dir.join("orders.xml"), "orders")],
738            GenerationConfig::new("common_types"),
739            &out,
740        )?;
741        let names: Vec<_> = set.modules().map(|m| m.path.as_str()).collect();
742        assert_eq!(names, ["common_types.rs", "orders.rs"]);
743        assert!(out.join("common_types.rs").is_file());
744        assert!(out.join("orders.rs").is_file());
745        let orders = fs::read_to_string(out.join("orders.rs"))?;
746        assert!(
747            orders.contains("price") || orders.contains("NewOrder"),
748            "shared Price must resolve in the consumer"
749        );
750        fs::remove_dir_all(&dir)?;
751        Ok(())
752    }
753
754    #[test]
755    fn generate_multi_rejects_mismatched_shared_module() -> Result<(), Box<dyn std::error::Error>> {
756        let dir = tempfile_dir()?;
757        write_multi_schemas(&dir)?;
758        let err = generate_multi_to_dir(
759            SchemaFile::new(&dir.join("common-types.xml"), "common_types"),
760            &[SchemaFile::new(&dir.join("orders.xml"), "orders")],
761            GenerationConfig::new("common_types").with_shared_module("other"),
762            dir.join("out"),
763        )
764        .unwrap_err();
765        let msg = err.to_string();
766        assert!(
767            msg.contains("shared_module") || msg.contains("other"),
768            "{msg}"
769        );
770        assert!(!dir.join("out/common_types.rs").exists());
771        fs::remove_dir_all(&dir)?;
772        Ok(())
773    }
774
775    #[test]
776    fn generate_multi_rejects_duplicate_module_names() -> Result<(), Box<dyn std::error::Error>> {
777        let dir = tempfile_dir()?;
778        write_multi_schemas(&dir)?;
779        let err = generate_multi_to_dir(
780            SchemaFile::new(&dir.join("common-types.xml"), "dup"),
781            &[SchemaFile::new(&dir.join("orders.xml"), "dup")],
782            GenerationConfig::new("dup"),
783            dir.join("out"),
784        )
785        .unwrap_err();
786        let msg = err.to_string();
787        assert!(msg.contains("duplicate") || msg.contains("dup"), "{msg}");
788        assert!(!dir.join("out/dup.rs").exists());
789        fs::remove_dir_all(&dir)?;
790        Ok(())
791    }
792
793    #[test]
794    fn generate_multi_late_consumer_failure_writes_nothing()
795    -> Result<(), Box<dyn std::error::Error>> {
796        let dir = tempfile_dir()?;
797        write_multi_schemas(&dir)?;
798        fs::write(
799            dir.join("bad.xml"),
800            r#"<?xml version="1.0"?>
801<messageSchema package="bad" id="2" version="1" byteOrder="littleEndian">
802  <message name="Bad" id="1">
803    <field name="x" id="1" type="NotAType"/>
804  </message>
805</messageSchema>
806"#,
807        )?;
808        let out = dir.join("out");
809        let err = generate_multi_to_dir(
810            SchemaFile::new(&dir.join("common-types.xml"), "common_types"),
811            &[
812                SchemaFile::new(&dir.join("orders.xml"), "orders"),
813                SchemaFile::new(&dir.join("bad.xml"), "bad"),
814            ],
815            GenerationConfig::new("common_types"),
816            &out,
817        )
818        .unwrap_err();
819        assert!(matches!(err, BuildError::Parse(_)), "{err:?}");
820        assert!(
821            !out.join("common_types.rs").exists() && !out.join("orders.rs").exists(),
822            "late consumer failure must not write earlier modules"
823        );
824        fs::remove_dir_all(&dir)?;
825        Ok(())
826    }
827
828    #[test]
829    fn generate_multi_watches_transitive_includes() -> Result<(), Box<dyn std::error::Error>> {
830        let dir = tempfile_dir()?;
831        let nested = dir.join("nested");
832        fs::create_dir_all(&nested)?;
833        fs::write(
834            dir.join("leaf.xml"),
835            r#"<?xml version="1.0"?>
836<types>
837  <composite name="Price">
838    <type name="mantissa" primitiveType="int64"/>
839    <type name="exponent" primitiveType="int8"/>
840  </composite>
841</types>
842"#,
843        )?;
844        fs::write(
845            nested.join("mid.xml"),
846            r#"<?xml version="1.0"?>
847<messageSchema package="mid" id="9" version="0">
848  <include href="../leaf.xml"/>
849  <types/>
850</messageSchema>
851"#,
852        )?;
853        fs::write(
854            dir.join("common-types.xml"),
855            r#"<?xml version="1.0"?>
856<messageSchema package="common" id="0" version="1" byteOrder="littleEndian">
857  <include href="nested/mid.xml"/>
858  <types>
859    <composite name="messageHeader">
860      <type name="blockLength" primitiveType="uint16"/>
861      <type name="templateId" primitiveType="uint16"/>
862      <type name="schemaId" primitiveType="uint16"/>
863      <type name="version" primitiveType="uint16"/>
864    </composite>
865  </types>
866</messageSchema>
867"#,
868        )?;
869        fs::write(
870            dir.join("orders.xml"),
871            r#"<?xml version="1.0"?>
872<messageSchema package="orders" id="1" version="1" byteOrder="littleEndian">
873  <message name="NewOrder" id="1">
874    <field name="price" id="1" type="Price"/>
875  </message>
876</messageSchema>
877"#,
878        )?;
879        let parsed = crate::xml::parse_file_with_deps(dir.join("common-types.xml"))?;
880        let names: Vec<String> = parsed
881            .dependencies
882            .iter()
883            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
884            .collect();
885        assert!(names.contains(&"leaf.xml".into()), "{names:?}");
886        let out = dir.join("out");
887        generate_multi_to_dir(
888            SchemaFile::new(&dir.join("common-types.xml"), "common_types"),
889            &[SchemaFile::new(&dir.join("orders.xml"), "orders")],
890            GenerationConfig::new("common_types"),
891            &out,
892        )?;
893        assert!(out.join("orders.rs").is_file());
894        fs::remove_dir_all(&dir)?;
895        Ok(())
896    }
897
898    /// Proves `BuildError::Parse` forwards the inner `ParseError`'s source +
899    /// span through `#[diagnostic(transparent)]` — the wrapped error still
900    /// renders a real snippet, not just the outer `{}`/`{:?}` message. This is
901    /// what a `build.rs` returning `miette::Result<()>` actually shows on a
902    /// malformed schema, instead of the raw `Debug` dump you get from
903    /// `Box<dyn std::error::Error>`.
904    #[test]
905    fn build_error_parse_variant_renders_source_snippet_via_miette()
906    -> Result<(), Box<dyn std::error::Error>> {
907        let bad_xml = r#"<messageSchema package="x" id="1" version="0">
908  <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>
909  <message name="M" id="1"><field name="f" id="1" type="bogus"/></message>
910</messageSchema>"#;
911
912        let dir = tempfile_dir()?;
913        let err = generate_str_to_dir(bad_xml, GenerationConfig::new("bad"), &dir).unwrap_err();
914        let _ = fs::remove_dir_all(&dir);
915
916        assert!(
917            matches!(err, BuildError::Parse(_)),
918            "expected BuildError::Parse, got {err:?}"
919        );
920
921        let mut rendered = String::new();
922        miette::GraphicalReportHandler::new_themed(miette::GraphicalTheme::unicode_nocolor())
923            .render_report(&mut rendered, &err)?;
924
925        assert!(rendered.contains("bogus"), "rendered:\n{rendered}");
926        assert!(
927            rendered.lines().count() > 1,
928            "expected a multi-line snippet through the transparent wrapper, got:\n{rendered}"
929        );
930
931        Ok(())
932    }
933
934    fn tempfile_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
935        static N: AtomicU64 = AtomicU64::new(0);
936        let dir = env::temp_dir().join(format!(
937            "ergo_sbe_build_test_{}_{}_{}",
938            std::process::id(),
939            N.fetch_add(1, Ordering::Relaxed),
940            std::time::SystemTime::now()
941                .duration_since(std::time::UNIX_EPOCH)?
942                .as_nanos()
943        ));
944        fs::create_dir_all(&dir)?;
945        Ok(dir)
946    }
947}