Skip to main content

movement_sdk/codegen/
build_helper.rs

1//! Build script helper for code generation.
2//!
3//! This module provides utilities for generating code at compile time via `build.rs`.
4//!
5//! # Example
6//!
7//! Add to your `build.rs`:
8//!
9//! ```rust,ignore
10//! use movement_sdk::codegen::build_helper;
11//!
12//! fn main() {
13//!     // Generate from a local ABI file
14//!     build_helper::generate_from_abi(
15//!         "abi/my_module.json",
16//!         "src/generated/",
17//!     ).expect("code generation failed");
18//!
19//!     // Generate from multiple modules
20//!     build_helper::generate_from_abis(&[
21//!         "abi/coin.json",
22//!         "abi/token.json",
23//!     ], "src/generated/").expect("code generation failed");
24//!
25//!     // Rerun if ABI files change
26//!     println!("cargo:rerun-if-changed=abi/");
27//! }
28//! ```
29//!
30//! # Directory Structure
31//!
32//! ```text
33//! my_project/
34//! ├── build.rs
35//! ├── abi/
36//! │   ├── my_module.json
37//! │   └── another_module.json
38//! └── src/
39//!     └── generated/
40//!         ├── mod.rs          (auto-generated)
41//!         ├── my_module.rs
42//!         └── another_module.rs
43//! ```
44
45use crate::api::response::MoveModuleABI;
46use crate::codegen::{GeneratorConfig, ModuleGenerator, MoveSourceParser};
47use crate::error::{MovementError, MovementResult};
48use std::fs;
49use std::path::Path;
50
51/// Returns true if `name` is a Rust keyword that cannot be used as a module name.
52fn is_rust_keyword(name: &str) -> bool {
53    matches!(
54        name,
55        "as" | "break"
56            | "const"
57            | "continue"
58            | "crate"
59            | "else"
60            | "enum"
61            | "extern"
62            | "false"
63            | "fn"
64            | "for"
65            | "if"
66            | "impl"
67            | "in"
68            | "let"
69            | "loop"
70            | "match"
71            | "mod"
72            | "move"
73            | "mut"
74            | "pub"
75            | "ref"
76            | "return"
77            | "self"
78            | "Self"
79            | "static"
80            | "struct"
81            | "super"
82            | "trait"
83            | "true"
84            | "type"
85            | "unsafe"
86            | "use"
87            | "where"
88            | "while"
89            | "async"
90            | "await"
91            | "dyn"
92    )
93}
94
95/// Validates that a module name is a safe Rust identifier (no path traversal, injection, or keywords).
96///
97/// # Security
98///
99/// This prevents:
100/// - Path traversal attacks via names like `../../../tmp/evil`
101/// - Invalid `pub mod` declarations in generated mod.rs (e.g., `pub mod fn;`)
102fn validate_module_name(name: &str) -> MovementResult<()> {
103    if name.is_empty() {
104        return Err(MovementError::Config(
105            "module name cannot be empty".to_string(),
106        ));
107    }
108
109    // Must be a valid Rust identifier: starts with letter or underscore,
110    // contains only alphanumeric or underscore characters
111    let mut chars = name.chars();
112    let first = chars.next().unwrap(); // safe: name is non-empty
113    if !first.is_ascii_alphabetic() && first != '_' {
114        return Err(MovementError::Config(format!(
115            "invalid module name '{name}': must start with a letter or underscore"
116        )));
117    }
118
119    if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
120        return Err(MovementError::Config(format!(
121            "invalid module name '{name}': must contain only ASCII alphanumeric characters or underscores"
122        )));
123    }
124
125    if is_rust_keyword(name) {
126        return Err(MovementError::Config(format!(
127            "invalid module name '{name}': Rust keywords cannot be used as module names"
128        )));
129    }
130
131    Ok(())
132}
133
134/// Configuration for build-time code generation.
135#[derive(Debug, Clone)]
136pub struct BuildConfig {
137    /// Generator configuration.
138    pub generator_config: GeneratorConfig,
139    /// Whether to generate a `mod.rs` file.
140    pub generate_mod_file: bool,
141    /// Whether to print build instructions to cargo.
142    pub print_cargo_instructions: bool,
143}
144
145impl Default for BuildConfig {
146    fn default() -> Self {
147        Self {
148            generator_config: GeneratorConfig::default(),
149            generate_mod_file: true,
150            print_cargo_instructions: true,
151        }
152    }
153}
154
155impl BuildConfig {
156    /// Creates a new build configuration.
157    #[must_use]
158    pub fn new() -> Self {
159        Self::default()
160    }
161
162    /// Sets whether to generate a mod.rs file.
163    #[must_use]
164    pub fn with_mod_file(mut self, enabled: bool) -> Self {
165        self.generate_mod_file = enabled;
166        self
167    }
168
169    /// Sets the generator configuration.
170    #[must_use]
171    pub fn with_generator_config(mut self, config: GeneratorConfig) -> Self {
172        self.generator_config = config;
173        self
174    }
175
176    /// Sets whether to print cargo instructions.
177    #[must_use]
178    pub fn with_cargo_instructions(mut self, enabled: bool) -> Self {
179        self.print_cargo_instructions = enabled;
180        self
181    }
182}
183
184/// Generates Rust code from a single ABI file.
185///
186/// # Arguments
187///
188/// * `abi_path` - Path to the ABI JSON file
189/// * `output_dir` - Directory where generated code will be written
190///
191/// # Errors
192///
193/// Returns an error if:
194/// * The ABI file cannot be read
195/// * The ABI JSON cannot be parsed
196/// * Code generation fails
197/// * The output directory cannot be created
198/// * The output file cannot be written
199///
200/// # Example
201///
202/// ```rust,ignore
203/// build_helper::generate_from_abi("abi/coin.json", "src/generated/")?;
204/// ```
205pub fn generate_from_abi(
206    abi_path: impl AsRef<Path>,
207    output_dir: impl AsRef<Path>,
208) -> MovementResult<()> {
209    generate_from_abi_with_config(abi_path, output_dir, BuildConfig::default())
210}
211
212/// Generates Rust code from a single ABI file with custom configuration.
213///
214/// # Errors
215///
216/// Returns an error if:
217/// * The ABI file cannot be read
218/// * The ABI JSON cannot be parsed
219/// * Code generation fails
220/// * The output directory cannot be created
221/// * The output file cannot be written
222pub fn generate_from_abi_with_config(
223    abi_path: impl AsRef<Path>,
224    output_dir: impl AsRef<Path>,
225    config: BuildConfig,
226) -> MovementResult<()> {
227    let abi_path = abi_path.as_ref();
228    let output_dir = output_dir.as_ref();
229
230    // Read and parse ABI
231    let abi_content = fs::read_to_string(abi_path).map_err(|e| {
232        MovementError::Config(format!(
233            "Failed to read ABI file {}: {}",
234            abi_path.display(),
235            e
236        ))
237    })?;
238
239    let abi: MoveModuleABI = serde_json::from_str(&abi_content)
240        .map_err(|e| MovementError::Config(format!("Failed to parse ABI JSON: {e}")))?;
241
242    // SECURITY: Validate module name to prevent path traversal
243    validate_module_name(&abi.name)?;
244
245    // Generate code
246    let generator = ModuleGenerator::new(&abi, config.generator_config);
247    let code = generator.generate()?;
248
249    // Create output directory
250    fs::create_dir_all(output_dir)
251        .map_err(|e| MovementError::Config(format!("Failed to create output directory: {e}")))?;
252
253    // Write output file
254    let output_filename = format!("{}.rs", abi.name);
255    let output_path = output_dir.join(&output_filename);
256
257    fs::write(&output_path, &code)
258        .map_err(|e| MovementError::Config(format!("Failed to write output file: {e}")))?;
259
260    if config.print_cargo_instructions {
261        println!("cargo:rerun-if-changed={}", abi_path.display());
262    }
263
264    Ok(())
265}
266
267/// Generates Rust code from multiple ABI files.
268///
269/// Also generates a `mod.rs` file that re-exports all generated modules.
270///
271/// # Arguments
272///
273/// * `abi_paths` - Paths to ABI JSON files
274/// * `output_dir` - Directory where generated code will be written
275///
276/// # Errors
277///
278/// Returns an error if:
279/// * Any ABI file cannot be read
280/// * Any ABI JSON cannot be parsed
281/// * Code generation fails for any module
282/// * The output directory cannot be created
283/// * Any output file cannot be written
284/// * The `mod.rs` file cannot be written
285///
286/// # Example
287///
288/// ```rust,ignore
289/// build_helper::generate_from_abis(&[
290///     "abi/coin.json",
291///     "abi/token.json",
292/// ], "src/generated/")?;
293/// ```
294pub fn generate_from_abis(
295    abi_paths: &[impl AsRef<Path>],
296    output_dir: impl AsRef<Path>,
297) -> MovementResult<()> {
298    generate_from_abis_with_config(abi_paths, output_dir, &BuildConfig::default())
299}
300
301/// Generates Rust code from multiple ABI files with custom configuration.
302///
303/// # Errors
304///
305/// Returns an error if:
306/// * Any ABI file cannot be read
307/// * Any ABI JSON cannot be parsed
308/// * Code generation fails for any module
309/// * The output directory cannot be created
310/// * Any output file cannot be written
311/// * The `mod.rs` file cannot be written (if enabled)
312pub fn generate_from_abis_with_config(
313    abi_paths: &[impl AsRef<Path>],
314    output_dir: impl AsRef<Path>,
315    config: &BuildConfig,
316) -> MovementResult<()> {
317    let output_dir = output_dir.as_ref();
318    let mut module_names = Vec::new();
319
320    // Generate code for each ABI
321    for abi_path in abi_paths {
322        let abi_path = abi_path.as_ref();
323
324        let abi_content = fs::read_to_string(abi_path).map_err(|e| {
325            MovementError::Config(format!(
326                "Failed to read ABI file {}: {}",
327                abi_path.display(),
328                e
329            ))
330        })?;
331
332        let abi: MoveModuleABI = serde_json::from_str(&abi_content).map_err(|e| {
333            MovementError::Config(format!(
334                "Failed to parse ABI JSON from {}: {}",
335                abi_path.display(),
336                e
337            ))
338        })?;
339
340        // SECURITY: Validate module name to prevent path traversal
341        validate_module_name(&abi.name)?;
342
343        let generator = ModuleGenerator::new(&abi, config.generator_config.clone());
344        let code = generator.generate()?;
345
346        // Create output directory
347        fs::create_dir_all(output_dir).map_err(|e| {
348            MovementError::Config(format!("Failed to create output directory: {e}"))
349        })?;
350
351        // Write output file
352        let output_filename = format!("{}.rs", abi.name);
353        let output_path = output_dir.join(&output_filename);
354
355        fs::write(&output_path, &code)
356            .map_err(|e| MovementError::Config(format!("Failed to write output file: {e}")))?;
357
358        module_names.push(abi.name);
359
360        if config.print_cargo_instructions {
361            println!("cargo:rerun-if-changed={}", abi_path.display());
362        }
363    }
364
365    // Generate mod.rs
366    if config.generate_mod_file && !module_names.is_empty() {
367        let mod_content = generate_mod_file(&module_names);
368        let mod_path = output_dir.join("mod.rs");
369
370        fs::write(&mod_path, mod_content)
371            .map_err(|e| MovementError::Config(format!("Failed to write mod.rs: {e}")))?;
372    }
373
374    Ok(())
375}
376
377/// Generates Rust code from an ABI file with Move source for better names.
378///
379/// # Arguments
380///
381/// * `abi_path` - Path to the ABI JSON file
382/// * `source_path` - Path to the Move source file
383/// * `output_dir` - Directory where generated code will be written
384///
385/// # Errors
386///
387/// Returns an error if:
388/// * The ABI file cannot be read
389/// * The ABI JSON cannot be parsed
390/// * The Move source file cannot be read
391/// * Code generation fails
392/// * The output directory cannot be created
393/// * The output file cannot be written
394pub fn generate_from_abi_with_source(
395    abi_path: impl AsRef<Path>,
396    source_path: impl AsRef<Path>,
397    output_dir: impl AsRef<Path>,
398) -> MovementResult<()> {
399    let abi_path = abi_path.as_ref();
400    let source_path = source_path.as_ref();
401    let output_dir = output_dir.as_ref();
402
403    // Read and parse ABI
404    let abi_content = fs::read_to_string(abi_path)
405        .map_err(|e| MovementError::Config(format!("Failed to read ABI file: {e}")))?;
406
407    let abi: MoveModuleABI = serde_json::from_str(&abi_content)
408        .map_err(|e| MovementError::Config(format!("Failed to parse ABI JSON: {e}")))?;
409
410    // Read and parse Move source
411    let source_content = fs::read_to_string(source_path)
412        .map_err(|e| MovementError::Config(format!("Failed to read Move source: {e}")))?;
413
414    let source_info = MoveSourceParser::parse(&source_content);
415
416    // SECURITY: Validate module name to prevent path traversal
417    validate_module_name(&abi.name)?;
418
419    // Generate code
420    let generator =
421        ModuleGenerator::new(&abi, GeneratorConfig::default()).with_source_info(source_info);
422    let code = generator.generate()?;
423
424    // Create output directory
425    fs::create_dir_all(output_dir)
426        .map_err(|e| MovementError::Config(format!("Failed to create output directory: {e}")))?;
427
428    // Write output file
429    let output_filename = format!("{}.rs", abi.name);
430    let output_path = output_dir.join(&output_filename);
431
432    fs::write(&output_path, &code)
433        .map_err(|e| MovementError::Config(format!("Failed to write output file: {e}")))?;
434
435    println!("cargo:rerun-if-changed={}", abi_path.display());
436    println!("cargo:rerun-if-changed={}", source_path.display());
437
438    Ok(())
439}
440
441/// Generates a mod.rs file for the given module names.
442fn generate_mod_file(module_names: &[String]) -> String {
443    use std::fmt::Write as _;
444    let mut content = String::new();
445    let _ = writeln!(&mut content, "//! Auto-generated module exports.");
446    let _ = writeln!(&mut content, "//!");
447    let _ = writeln!(
448        &mut content,
449        "//! This file was auto-generated by movement-sdk codegen."
450    );
451    let _ = writeln!(&mut content, "//! Do not edit manually.");
452    let _ = writeln!(&mut content);
453
454    for name in module_names {
455        // SECURITY: Module names are validated by validate_module_name() before reaching here,
456        // but double-check they are safe identifiers to prevent code injection in mod.rs
457        debug_assert!(
458            !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
459            "module name should have been validated"
460        );
461        let _ = writeln!(&mut content, "pub mod {name};");
462    }
463    let _ = writeln!(&mut content);
464
465    // Re-export all modules
466    let _ = writeln!(&mut content, "// Re-exports for convenience");
467    for name in module_names {
468        let _ = writeln!(&mut content, "pub use {name}::*;");
469    }
470
471    content
472}
473
474/// Scans a directory for ABI files and generates code for all of them.
475///
476/// # Arguments
477///
478/// * `abi_dir` - Directory containing ABI JSON files
479/// * `output_dir` - Directory where generated code will be written
480///
481/// # Errors
482///
483/// Returns an error if:
484/// * The directory cannot be read
485/// * No JSON files are found in the directory
486/// * Any ABI file cannot be read or parsed
487/// * Code generation fails for any module
488/// * The output directory cannot be created
489/// * Any output file cannot be written
490///
491/// # Example
492///
493/// ```rust,ignore
494/// build_helper::generate_from_directory("abi/", "src/generated/")?;
495/// ```
496pub fn generate_from_directory(
497    abi_dir: impl AsRef<Path>,
498    output_dir: impl AsRef<Path>,
499) -> MovementResult<()> {
500    let abi_dir = abi_dir.as_ref();
501
502    let entries = fs::read_dir(abi_dir)
503        .map_err(|e| MovementError::Config(format!("Failed to read ABI directory: {e}")))?;
504
505    let abi_paths: Vec<_> = entries
506        .filter_map(Result::ok)
507        .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
508        .map(|e| e.path())
509        .collect();
510
511    if abi_paths.is_empty() {
512        return Err(MovementError::Config(format!(
513            "No JSON files found in {}",
514            abi_dir.display()
515        )));
516    }
517
518    // Convert PathBuf to Path references for the function
519    let path_refs: Vec<&Path> = abi_paths.iter().map(std::path::PathBuf::as_path).collect();
520    generate_from_abis(&path_refs, output_dir)
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use std::io::Write;
527    use tempfile::TempDir;
528
529    fn sample_abi_json() -> &'static str {
530        r#"{
531            "address": "0x1",
532            "name": "coin",
533            "exposed_functions": [
534                {
535                    "name": "transfer",
536                    "visibility": "public",
537                    "is_entry": true,
538                    "is_view": false,
539                    "generic_type_params": [{"constraints": []}],
540                    "params": ["&signer", "address", "u64"],
541                    "return": []
542                }
543            ],
544            "structs": []
545        }"#
546    }
547
548    #[test]
549    fn test_generate_from_abi() {
550        let temp_dir = TempDir::new().unwrap();
551        let abi_path = temp_dir.path().join("coin.json");
552        let output_dir = temp_dir.path().join("generated");
553
554        // Write sample ABI
555        let mut file = fs::File::create(&abi_path).unwrap();
556        file.write_all(sample_abi_json().as_bytes()).unwrap();
557
558        // Generate
559        let config = BuildConfig::new().with_cargo_instructions(false);
560        generate_from_abi_with_config(&abi_path, &output_dir, config).unwrap();
561
562        // Verify output exists
563        let output_path = output_dir.join("coin.rs");
564        assert!(output_path.exists());
565
566        // Verify content
567        let content = fs::read_to_string(&output_path).unwrap();
568        assert!(content.contains("Generated Rust bindings"));
569        assert!(content.contains("pub fn transfer"));
570    }
571
572    #[test]
573    fn test_generate_mod_file() {
574        let modules = vec!["coin".to_string(), "token".to_string()];
575        let mod_content = generate_mod_file(&modules);
576
577        assert!(mod_content.contains("pub mod coin;"));
578        assert!(mod_content.contains("pub mod token;"));
579        assert!(mod_content.contains("pub use coin::*;"));
580        assert!(mod_content.contains("pub use token::*;"));
581    }
582
583    #[test]
584    fn test_build_config() {
585        let config = BuildConfig::new()
586            .with_mod_file(false)
587            .with_cargo_instructions(false);
588
589        assert!(!config.generate_mod_file);
590        assert!(!config.print_cargo_instructions);
591    }
592}