Skip to main content

xidl_build/
lib.rs

1//! Build-script support for invoking `xidlc` from `build.rs`.
2//!
3//! This crate wraps the `xidlc` code generator in a small builder API so Cargo
4//! build scripts can generate code or schema artifacts without shelling out to
5//! the `xidlc` binary.
6//!
7//! # Example
8//!
9//! ```no_run
10//! fn main() -> Result<(), xidl_build::IdlcError> {
11//!     println!("cargo:rerun-if-changed=idl/hello_world.idl");
12//!
13//!     xidl_build::Builder::new()
14//!         .with_lang("rust")
15//!         .compile(&["idl/hello_world.idl"])?;
16//!
17//!     Ok(())
18//! }
19//! ```
20//!
21//! By default, generated files are written to Cargo's `OUT_DIR`, and both
22//! client and server artifacts are enabled.
23use std::env;
24use std::fs;
25use std::path::{Path, PathBuf};
26
27/// The error type returned by the underlying `xidlc` compiler driver.
28pub use xidlc::error::IdlcError;
29
30/// Configures and runs IDL code generation from a Cargo build script.
31///
32/// `Builder` provides a small fluent interface around `xidlc`'s generator
33/// options. The default configuration targets Rust output, writes into
34/// `OUT_DIR`, and enables both client and server generation.
35///
36/// # Example
37///
38/// ```no_run
39/// fn main() -> Result<(), xidl_build::IdlcError> {
40///     xidl_build::Builder::new()
41///         .with_lang("openapi")
42///         .with_output_filename("api.json")
43///         .compile(&["idl/petstore.idl"])?;
44///
45///     Ok(())
46/// }
47/// ```
48#[derive(Clone, Debug)]
49pub struct Builder {
50    lang: String,
51    out_dir: Option<PathBuf>,
52    output_filename: Option<PathBuf>,
53    client: bool,
54    server: bool,
55    mock: bool,
56}
57
58impl Default for Builder {
59    fn default() -> Self {
60        Self {
61            lang: "rust".to_string(),
62            out_dir: None,
63            output_filename: None,
64            client: true,
65            server: true,
66            mock: false,
67        }
68    }
69}
70
71impl Builder {
72    /// Creates a builder with the default configuration.
73    ///
74    /// Defaults:
75    /// - language: `rust`
76    /// - output directory: Cargo `OUT_DIR`
77    /// - client generation: enabled
78    /// - server generation: enabled
79    /// - mock generation: disabled
80    pub fn new() -> Self {
81        Self::default()
82    }
83
84    /// Selects the target generator language.
85    ///
86    /// The exact accepted values are defined by `xidlc`, such as `rust`,
87    /// `openapi`, and `openrpc`.
88    pub fn with_lang(mut self, lang: impl Into<String>) -> Self {
89        self.lang = lang.into();
90        self
91    }
92
93    /// Overrides the output directory used by the generator.
94    ///
95    /// When not set, [`compile`](Self::compile) reads Cargo's `OUT_DIR`
96    /// environment variable.
97    pub fn with_out_dir(mut self, out_dir: impl Into<PathBuf>) -> Self {
98        self.out_dir = Some(out_dir.into());
99        self
100    }
101
102    /// Enables or disables mock generation.
103    ///
104    /// When enabled, generated traits will be annotated with `#[mockall::automock]`.
105    pub fn with_mock(mut self, mock: bool) -> Self {
106        self.mock = mock;
107        self
108    }
109
110    /// Renames the generated single-file artifact after generation.
111    ///
112    /// This currently only works for generators that emit exactly one known file:
113    /// `openapi` (`openapi_{filename}.json`) and `openrpc` (`openrpc.json`).
114    ///
115    /// Relative paths are resolved against the final output directory. Absolute
116    /// paths are used as-is.
117    pub fn with_output_filename(mut self, filename: impl Into<PathBuf>) -> Self {
118        self.output_filename = Some(filename.into());
119        self
120    }
121
122    /// Enables or disables client-side artifact generation.
123    pub fn with_client(mut self, value: bool) -> Self {
124        self.client = value;
125        self
126    }
127
128    /// Enables or disables server-side artifact generation.
129    pub fn with_server(mut self, value: bool) -> Self {
130        self.server = value;
131        self
132    }
133}
134
135impl Builder {
136    /// Runs the configured generator for the provided IDL input files.
137    ///
138    /// If no output directory was configured with
139    /// [`with_out_dir`](Self::with_out_dir), this method requires Cargo's
140    /// `OUT_DIR` environment variable to be present.
141    ///
142    /// When [`with_output_filename`](Self::with_output_filename) is set, the
143    /// generated artifact is renamed after successful code generation.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if:
148    /// - `OUT_DIR` is required but missing
149    /// - code generation fails
150    /// - the requested output rename is unsupported or cannot be completed
151    pub fn compile(&self, inputs: &[impl AsRef<Path>]) -> Result<(), IdlcError> {
152        let out_dir = match &self.out_dir {
153            Some(path) => path.clone(),
154            None => PathBuf::from(
155                env::var("OUT_DIR")
156                    .map_err(|err| IdlcError::fmt(format!("OUT_DIR is not set: {err}")))?,
157            ),
158        };
159        let inputs_paths: Vec<PathBuf> = inputs.iter().map(|p| p.as_ref().to_path_buf()).collect();
160        let args = xidlc::driver::ArgsGenerate {
161            lang: self.lang.clone(),
162            out_dir: out_dir.to_string_lossy().to_string(),
163            files: inputs_paths.clone(),
164            client: self.client,
165            server: self.server,
166            mock: self.mock,
167            dry_run: false,
168        };
169
170        tokio::runtime::Builder::new_current_thread()
171            .enable_all()
172            .build()
173            .unwrap()
174            .block_on(async { xidlc::driver::Driver::run(args).await })?;
175
176        if let Some(custom_name) = &self.output_filename {
177            self.apply_output_filename(&out_dir, custom_name, &inputs_paths)?;
178        }
179
180        Ok(())
181    }
182
183    fn apply_output_filename(
184        &self,
185        out_dir: &Path,
186        custom_name: &Path,
187        inputs: &[PathBuf],
188    ) -> Result<(), IdlcError> {
189        if out_dir == Path::new("-") {
190            return Err(IdlcError::fmt(
191                "with_output_filename is not supported when out_dir is '-'",
192            ));
193        }
194
195        let default_name = match self.lang.as_str() {
196            "openapi" => {
197                let stem = inputs
198                    .first()
199                    .and_then(|p| p.file_stem())
200                    .and_then(|s| s.to_str())
201                    .unwrap_or("openapi");
202                format!("openapi_{}.json", stem)
203            }
204            "openrpc" | "open-rpc" => "openrpc.json".to_string(),
205            _ => {
206                return Err(IdlcError::fmt(format!(
207                    "with_output_filename is only supported for openapi/openrpc generators, got '{}'",
208                    self.lang
209                )));
210            }
211        };
212
213        let src = out_dir.join(&default_name);
214        if !src.exists() {
215            return Err(IdlcError::fmt(format!(
216                "generated file '{}' does not exist in '{}'",
217                default_name,
218                out_dir.display()
219            )));
220        }
221
222        let dst = if custom_name.is_absolute() {
223            custom_name.to_path_buf()
224        } else {
225            out_dir.join(custom_name)
226        };
227        if src == dst {
228            return Ok(());
229        }
230        if let Some(parent) = dst.parent() {
231            fs::create_dir_all(parent)?;
232        }
233        fs::rename(src, dst)?;
234        Ok(())
235    }
236}