Skip to main content

aptos_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 aptos_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::{AptosError, AptosResult};
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) -> AptosResult<()> {
103    if name.is_empty() {
104        return Err(AptosError::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(AptosError::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(AptosError::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(AptosError::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) -> AptosResult<()> {
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) -> AptosResult<()> {
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        AptosError::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| AptosError::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| AptosError::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| AptosError::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) -> AptosResult<()> {
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) -> AptosResult<()> {
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            AptosError::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            AptosError::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)
348            .map_err(|e| AptosError::Config(format!("Failed to create output directory: {e}")))?;
349
350        // Write output file
351        let output_filename = format!("{}.rs", abi.name);
352        let output_path = output_dir.join(&output_filename);
353
354        fs::write(&output_path, &code)
355            .map_err(|e| AptosError::Config(format!("Failed to write output file: {e}")))?;
356
357        module_names.push(abi.name);
358
359        if config.print_cargo_instructions {
360            println!("cargo:rerun-if-changed={}", abi_path.display());
361        }
362    }
363
364    // Generate mod.rs
365    if config.generate_mod_file && !module_names.is_empty() {
366        let mod_content = generate_mod_file(&module_names);
367        let mod_path = output_dir.join("mod.rs");
368
369        fs::write(&mod_path, mod_content)
370            .map_err(|e| AptosError::Config(format!("Failed to write mod.rs: {e}")))?;
371    }
372
373    Ok(())
374}
375
376/// Generates Rust code from an ABI file with Move source for better names.
377///
378/// # Arguments
379///
380/// * `abi_path` - Path to the ABI JSON file
381/// * `source_path` - Path to the Move source file
382/// * `output_dir` - Directory where generated code will be written
383///
384/// # Errors
385///
386/// Returns an error if:
387/// * The ABI file cannot be read
388/// * The ABI JSON cannot be parsed
389/// * The Move source file cannot be read
390/// * Code generation fails
391/// * The output directory cannot be created
392/// * The output file cannot be written
393pub fn generate_from_abi_with_source(
394    abi_path: impl AsRef<Path>,
395    source_path: impl AsRef<Path>,
396    output_dir: impl AsRef<Path>,
397) -> AptosResult<()> {
398    let abi_path = abi_path.as_ref();
399    let source_path = source_path.as_ref();
400    let output_dir = output_dir.as_ref();
401
402    // Read and parse ABI
403    let abi_content = fs::read_to_string(abi_path)
404        .map_err(|e| AptosError::Config(format!("Failed to read ABI file: {e}")))?;
405
406    let abi: MoveModuleABI = serde_json::from_str(&abi_content)
407        .map_err(|e| AptosError::Config(format!("Failed to parse ABI JSON: {e}")))?;
408
409    // Read and parse Move source
410    let source_content = fs::read_to_string(source_path)
411        .map_err(|e| AptosError::Config(format!("Failed to read Move source: {e}")))?;
412
413    let source_info = MoveSourceParser::parse(&source_content);
414
415    // SECURITY: Validate module name to prevent path traversal
416    validate_module_name(&abi.name)?;
417
418    // Generate code
419    let generator =
420        ModuleGenerator::new(&abi, GeneratorConfig::default()).with_source_info(source_info);
421    let code = generator.generate()?;
422
423    // Create output directory
424    fs::create_dir_all(output_dir)
425        .map_err(|e| AptosError::Config(format!("Failed to create output directory: {e}")))?;
426
427    // Write output file
428    let output_filename = format!("{}.rs", abi.name);
429    let output_path = output_dir.join(&output_filename);
430
431    fs::write(&output_path, &code)
432        .map_err(|e| AptosError::Config(format!("Failed to write output file: {e}")))?;
433
434    println!("cargo:rerun-if-changed={}", abi_path.display());
435    println!("cargo:rerun-if-changed={}", source_path.display());
436
437    Ok(())
438}
439
440/// Generates a mod.rs file for the given module names.
441fn generate_mod_file(module_names: &[String]) -> String {
442    use std::fmt::Write as _;
443    let mut content = String::new();
444    let _ = writeln!(&mut content, "//! Auto-generated module exports.");
445    let _ = writeln!(&mut content, "//!");
446    let _ = writeln!(
447        &mut content,
448        "//! This file was auto-generated by aptos-sdk codegen."
449    );
450    let _ = writeln!(&mut content, "//! Do not edit manually.");
451    let _ = writeln!(&mut content);
452
453    for name in module_names {
454        // SECURITY: Module names are validated by validate_module_name() before reaching here,
455        // but double-check they are safe identifiers to prevent code injection in mod.rs
456        debug_assert!(
457            !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
458            "module name should have been validated"
459        );
460        let _ = writeln!(&mut content, "pub mod {name};");
461    }
462    let _ = writeln!(&mut content);
463
464    // Re-export all modules
465    let _ = writeln!(&mut content, "// Re-exports for convenience");
466    for name in module_names {
467        let _ = writeln!(&mut content, "pub use {name}::*;");
468    }
469
470    content
471}
472
473/// Scans a directory for ABI files and generates code for all of them.
474///
475/// # Arguments
476///
477/// * `abi_dir` - Directory containing ABI JSON files
478/// * `output_dir` - Directory where generated code will be written
479///
480/// # Errors
481///
482/// Returns an error if:
483/// * The directory cannot be read
484/// * No JSON files are found in the directory
485/// * Any ABI file cannot be read or parsed
486/// * Code generation fails for any module
487/// * The output directory cannot be created
488/// * Any output file cannot be written
489///
490/// # Example
491///
492/// ```rust,ignore
493/// build_helper::generate_from_directory("abi/", "src/generated/")?;
494/// ```
495pub fn generate_from_directory(
496    abi_dir: impl AsRef<Path>,
497    output_dir: impl AsRef<Path>,
498) -> AptosResult<()> {
499    let abi_dir = abi_dir.as_ref();
500
501    let entries = fs::read_dir(abi_dir)
502        .map_err(|e| AptosError::Config(format!("Failed to read ABI directory: {e}")))?;
503
504    let abi_paths: Vec<_> = entries
505        .filter_map(Result::ok)
506        .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
507        .map(|e| e.path())
508        .collect();
509
510    if abi_paths.is_empty() {
511        return Err(AptosError::Config(format!(
512            "No JSON files found in {}",
513            abi_dir.display()
514        )));
515    }
516
517    // Convert PathBuf to Path references for the function
518    let path_refs: Vec<&Path> = abi_paths.iter().map(std::path::PathBuf::as_path).collect();
519    generate_from_abis(&path_refs, output_dir)
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use std::io::Write;
526    use tempfile::TempDir;
527
528    fn sample_abi_json() -> &'static str {
529        r#"{
530            "address": "0x1",
531            "name": "coin",
532            "exposed_functions": [
533                {
534                    "name": "transfer",
535                    "visibility": "public",
536                    "is_entry": true,
537                    "is_view": false,
538                    "generic_type_params": [{"constraints": []}],
539                    "params": ["&signer", "address", "u64"],
540                    "return": []
541                }
542            ],
543            "structs": []
544        }"#
545    }
546
547    /// Builds a minimal, valid ABI JSON string for a module with the given name.
548    fn abi_json_with_name(name: &str) -> String {
549        format!(
550            r#"{{
551                "address": "0x1",
552                "name": "{name}",
553                "exposed_functions": [
554                    {{
555                        "name": "transfer",
556                        "visibility": "public",
557                        "is_entry": true,
558                        "is_view": false,
559                        "generic_type_params": [{{"constraints": []}}],
560                        "params": ["&signer", "address", "u64"],
561                        "return": []
562                    }}
563                ],
564                "structs": []
565            }}"#
566        )
567    }
568
569    /// Writes `contents` to `path`, creating parent dirs as needed.
570    fn write_file(path: &Path, contents: &str) {
571        if let Some(parent) = path.parent() {
572            fs::create_dir_all(parent).unwrap();
573        }
574        let mut file = fs::File::create(path).unwrap();
575        file.write_all(contents.as_bytes()).unwrap();
576    }
577
578    #[test]
579    fn test_generate_from_abi() {
580        let temp_dir = TempDir::new().unwrap();
581        let abi_path = temp_dir.path().join("coin.json");
582        let output_dir = temp_dir.path().join("generated");
583
584        // Write sample ABI
585        let mut file = fs::File::create(&abi_path).unwrap();
586        file.write_all(sample_abi_json().as_bytes()).unwrap();
587
588        // Generate
589        let config = BuildConfig::new().with_cargo_instructions(false);
590        generate_from_abi_with_config(&abi_path, &output_dir, config).unwrap();
591
592        // Verify output exists
593        let output_path = output_dir.join("coin.rs");
594        assert!(output_path.exists());
595
596        // Verify content
597        let content = fs::read_to_string(&output_path).unwrap();
598        assert!(content.contains("Generated Rust bindings"));
599        assert!(content.contains("pub fn transfer"));
600    }
601
602    #[test]
603    fn test_generate_mod_file() {
604        let modules = vec!["coin".to_string(), "token".to_string()];
605        let mod_content = generate_mod_file(&modules);
606
607        assert!(mod_content.contains("pub mod coin;"));
608        assert!(mod_content.contains("pub mod token;"));
609        assert!(mod_content.contains("pub use coin::*;"));
610        assert!(mod_content.contains("pub use token::*;"));
611    }
612
613    #[test]
614    fn test_build_config() {
615        let config = BuildConfig::new()
616            .with_mod_file(false)
617            .with_cargo_instructions(false);
618
619        assert!(!config.generate_mod_file);
620        assert!(!config.print_cargo_instructions);
621    }
622
623    #[test]
624    fn test_build_config_defaults() {
625        let config = BuildConfig::default();
626        assert!(config.generate_mod_file);
627        assert!(config.print_cargo_instructions);
628        // Debug/Clone are derived; exercise them so they are covered.
629        let cloned = config.clone();
630        assert!(cloned.generate_mod_file);
631        assert!(format!("{config:?}").contains("BuildConfig"));
632    }
633
634    #[test]
635    fn test_build_config_with_generator_config() {
636        let gen_config = GeneratorConfig::default();
637        let config = BuildConfig::new().with_generator_config(gen_config);
638        // The builder returns Self; the generator config field is populated.
639        assert!(config.generate_mod_file);
640    }
641
642    // --- validate_module_name ---------------------------------------------
643
644    #[test]
645    fn test_validate_module_name_valid() {
646        assert!(validate_module_name("coin").is_ok());
647        assert!(validate_module_name("_private").is_ok());
648        assert!(validate_module_name("token_v2").is_ok());
649        assert!(validate_module_name("MyModule").is_ok());
650        assert!(validate_module_name("a1b2c3").is_ok());
651    }
652
653    #[test]
654    fn test_validate_module_name_empty() {
655        let err = validate_module_name("").unwrap_err();
656        assert!(matches!(err, AptosError::Config(ref m) if m.contains("empty")));
657    }
658
659    #[test]
660    fn test_validate_module_name_bad_first_char() {
661        // Starts with a digit.
662        let err = validate_module_name("1coin").unwrap_err();
663        assert!(matches!(err, AptosError::Config(ref m) if m.contains("must start with")));
664        // Starts with a non-alphanumeric character (path traversal attempt).
665        let err = validate_module_name("../evil").unwrap_err();
666        assert!(matches!(err, AptosError::Config(ref m) if m.contains("must start with")));
667    }
668
669    #[test]
670    fn test_validate_module_name_bad_inner_char() {
671        // Contains a path separator.
672        let err = validate_module_name("coin/evil").unwrap_err();
673        assert!(matches!(err, AptosError::Config(ref m) if m.contains("only ASCII")));
674        // Contains a non-ASCII character.
675        let err = validate_module_name("café").unwrap_err();
676        assert!(matches!(err, AptosError::Config(ref m) if m.contains("only ASCII")));
677    }
678
679    #[test]
680    fn test_validate_module_name_keyword() {
681        for kw in ["fn", "mod", "struct", "type", "use", "async", "dyn", "move"] {
682            let err = validate_module_name(kw).unwrap_err();
683            assert!(
684                matches!(err, AptosError::Config(ref m) if m.contains("keyword")),
685                "expected keyword rejection for {kw}"
686            );
687        }
688    }
689
690    #[test]
691    fn test_is_rust_keyword() {
692        assert!(is_rust_keyword("fn"));
693        assert!(is_rust_keyword("Self"));
694        assert!(is_rust_keyword("await"));
695        assert!(!is_rust_keyword("coin"));
696        assert!(!is_rust_keyword("self_ish"));
697    }
698
699    // --- generate_from_abi -------------------------------------------------
700
701    #[test]
702    fn test_generate_from_abi_default_wrapper() {
703        let temp_dir = TempDir::new().unwrap();
704        let abi_path = temp_dir.path().join("coin.json");
705        let output_dir = temp_dir.path().join("generated");
706        write_file(&abi_path, sample_abi_json());
707
708        generate_from_abi(&abi_path, &output_dir).unwrap();
709
710        let content = fs::read_to_string(output_dir.join("coin.rs")).unwrap();
711        assert!(content.contains("pub fn transfer"));
712    }
713
714    #[test]
715    fn test_generate_from_abi_missing_file() {
716        let temp_dir = TempDir::new().unwrap();
717        let abi_path = temp_dir.path().join("does_not_exist.json");
718        let output_dir = temp_dir.path().join("generated");
719
720        let err = generate_from_abi(&abi_path, &output_dir).unwrap_err();
721        assert!(matches!(err, AptosError::Config(ref m) if m.contains("Failed to read ABI file")));
722    }
723
724    #[test]
725    fn test_generate_from_abi_invalid_json() {
726        let temp_dir = TempDir::new().unwrap();
727        let abi_path = temp_dir.path().join("bad.json");
728        let output_dir = temp_dir.path().join("generated");
729        write_file(&abi_path, "{ this is not valid json");
730
731        let err = generate_from_abi(&abi_path, &output_dir).unwrap_err();
732        assert!(matches!(err, AptosError::Config(ref m) if m.contains("Failed to parse ABI JSON")));
733    }
734
735    #[test]
736    fn test_generate_from_abi_rejects_bad_module_name() {
737        let temp_dir = TempDir::new().unwrap();
738        let abi_path = temp_dir.path().join("evil.json");
739        let output_dir = temp_dir.path().join("generated");
740        // A path-traversal module name should be rejected before any file is written.
741        write_file(&abi_path, &abi_json_with_name("../../evil"));
742
743        let err = generate_from_abi(&abi_path, &output_dir).unwrap_err();
744        assert!(matches!(err, AptosError::Config(ref m) if m.contains("must start with")));
745        assert!(!output_dir.exists());
746    }
747
748    // --- generate_from_abis ------------------------------------------------
749
750    #[test]
751    fn test_generate_from_abis_multiple_with_mod_file() {
752        let temp_dir = TempDir::new().unwrap();
753        let coin_path = temp_dir.path().join("coin.json");
754        let token_path = temp_dir.path().join("token.json");
755        let output_dir = temp_dir.path().join("generated");
756        write_file(&coin_path, &abi_json_with_name("coin"));
757        write_file(&token_path, &abi_json_with_name("token"));
758
759        let config = BuildConfig::new().with_cargo_instructions(false);
760        generate_from_abis_with_config(&[&coin_path, &token_path], &output_dir, &config).unwrap();
761
762        assert!(output_dir.join("coin.rs").exists());
763        assert!(output_dir.join("token.rs").exists());
764        let mod_content = fs::read_to_string(output_dir.join("mod.rs")).unwrap();
765        assert!(mod_content.contains("pub mod coin;"));
766        assert!(mod_content.contains("pub mod token;"));
767    }
768
769    #[test]
770    fn test_generate_from_abis_no_mod_file_when_disabled() {
771        let temp_dir = TempDir::new().unwrap();
772        let coin_path = temp_dir.path().join("coin.json");
773        let output_dir = temp_dir.path().join("generated");
774        write_file(&coin_path, &abi_json_with_name("coin"));
775
776        let config = BuildConfig::new()
777            .with_mod_file(false)
778            .with_cargo_instructions(false);
779        generate_from_abis_with_config(&[&coin_path], &output_dir, &config).unwrap();
780
781        assert!(output_dir.join("coin.rs").exists());
782        assert!(!output_dir.join("mod.rs").exists());
783    }
784
785    #[test]
786    fn test_generate_from_abis_default_wrapper() {
787        let temp_dir = TempDir::new().unwrap();
788        let coin_path = temp_dir.path().join("coin.json");
789        let output_dir = temp_dir.path().join("generated");
790        write_file(&coin_path, &abi_json_with_name("coin"));
791
792        generate_from_abis(&[&coin_path], &output_dir).unwrap();
793        assert!(output_dir.join("coin.rs").exists());
794        // The default config generates a mod.rs.
795        assert!(output_dir.join("mod.rs").exists());
796    }
797
798    #[test]
799    fn test_generate_from_abis_missing_file_errors() {
800        let temp_dir = TempDir::new().unwrap();
801        let missing = temp_dir.path().join("missing.json");
802        let output_dir = temp_dir.path().join("generated");
803
804        let err = generate_from_abis(&[&missing], &output_dir).unwrap_err();
805        assert!(matches!(err, AptosError::Config(ref m) if m.contains("Failed to read ABI file")));
806    }
807
808    #[test]
809    fn test_generate_from_abis_invalid_json_errors() {
810        let temp_dir = TempDir::new().unwrap();
811        let bad = temp_dir.path().join("bad.json");
812        let output_dir = temp_dir.path().join("generated");
813        write_file(&bad, "not json");
814
815        let err = generate_from_abis(&[&bad], &output_dir).unwrap_err();
816        assert!(
817            matches!(err, AptosError::Config(ref m) if m.contains("Failed to parse ABI JSON from"))
818        );
819    }
820
821    #[test]
822    fn test_generate_from_abis_bad_module_name_errors() {
823        let temp_dir = TempDir::new().unwrap();
824        let evil = temp_dir.path().join("evil.json");
825        let output_dir = temp_dir.path().join("generated");
826        write_file(&evil, &abi_json_with_name("mod"));
827
828        let err = generate_from_abis(&[&evil], &output_dir).unwrap_err();
829        assert!(matches!(err, AptosError::Config(ref m) if m.contains("keyword")));
830    }
831
832    #[test]
833    fn test_generate_from_abis_empty_input_writes_nothing() {
834        let temp_dir = TempDir::new().unwrap();
835        let output_dir = temp_dir.path().join("generated");
836        let empty: [&Path; 0] = [];
837
838        // No ABIs -> succeeds but produces no mod.rs (module_names is empty).
839        generate_from_abis(&empty, &output_dir).unwrap();
840        assert!(!output_dir.join("mod.rs").exists());
841    }
842
843    // --- generate_from_abi_with_source ------------------------------------
844
845    #[test]
846    fn test_generate_from_abi_with_source() {
847        let temp_dir = TempDir::new().unwrap();
848        let abi_path = temp_dir.path().join("coin.json");
849        let source_path = temp_dir.path().join("coin.move");
850        let output_dir = temp_dir.path().join("generated");
851        write_file(&abi_path, sample_abi_json());
852        write_file(
853            &source_path,
854            "module 0x1::coin {\n\
855             /// Transfers coins.\n\
856             public entry fun transfer(from: &signer, to: address, amount: u64) {}\n\
857             }\n",
858        );
859
860        generate_from_abi_with_source(&abi_path, &source_path, &output_dir).unwrap();
861
862        let content = fs::read_to_string(output_dir.join("coin.rs")).unwrap();
863        assert!(content.contains("pub fn transfer"));
864    }
865
866    #[test]
867    fn test_generate_from_abi_with_source_missing_abi() {
868        let temp_dir = TempDir::new().unwrap();
869        let abi_path = temp_dir.path().join("missing.json");
870        let source_path = temp_dir.path().join("coin.move");
871        let output_dir = temp_dir.path().join("generated");
872        write_file(&source_path, "module 0x1::coin {}");
873
874        let err = generate_from_abi_with_source(&abi_path, &source_path, &output_dir).unwrap_err();
875        assert!(matches!(err, AptosError::Config(ref m) if m.contains("Failed to read ABI file")));
876    }
877
878    #[test]
879    fn test_generate_from_abi_with_source_missing_source() {
880        let temp_dir = TempDir::new().unwrap();
881        let abi_path = temp_dir.path().join("coin.json");
882        let source_path = temp_dir.path().join("missing.move");
883        let output_dir = temp_dir.path().join("generated");
884        write_file(&abi_path, sample_abi_json());
885
886        let err = generate_from_abi_with_source(&abi_path, &source_path, &output_dir).unwrap_err();
887        assert!(
888            matches!(err, AptosError::Config(ref m) if m.contains("Failed to read Move source"))
889        );
890    }
891
892    #[test]
893    fn test_generate_from_abi_with_source_bad_module_name() {
894        let temp_dir = TempDir::new().unwrap();
895        let abi_path = temp_dir.path().join("evil.json");
896        let source_path = temp_dir.path().join("evil.move");
897        let output_dir = temp_dir.path().join("generated");
898        write_file(&abi_path, &abi_json_with_name("has space"));
899        write_file(&source_path, "module 0x1::x {}");
900
901        let err = generate_from_abi_with_source(&abi_path, &source_path, &output_dir).unwrap_err();
902        assert!(matches!(err, AptosError::Config(ref m) if m.contains("only ASCII")));
903    }
904
905    // --- generate_from_directory ------------------------------------------
906
907    #[test]
908    fn test_generate_from_directory_success() {
909        let temp_dir = TempDir::new().unwrap();
910        let abi_dir = temp_dir.path().join("abi");
911        let output_dir = temp_dir.path().join("generated");
912        write_file(&abi_dir.join("coin.json"), &abi_json_with_name("coin"));
913        write_file(&abi_dir.join("token.json"), &abi_json_with_name("token"));
914        // A non-JSON file should be ignored by the extension filter.
915        write_file(&abi_dir.join("README.txt"), "ignore me");
916
917        generate_from_directory(&abi_dir, &output_dir).unwrap();
918
919        assert!(output_dir.join("coin.rs").exists());
920        assert!(output_dir.join("token.rs").exists());
921        assert!(output_dir.join("mod.rs").exists());
922    }
923
924    #[test]
925    fn test_generate_from_directory_no_json_files() {
926        let temp_dir = TempDir::new().unwrap();
927        let abi_dir = temp_dir.path().join("abi");
928        let output_dir = temp_dir.path().join("generated");
929        fs::create_dir_all(&abi_dir).unwrap();
930        write_file(&abi_dir.join("notes.txt"), "no abis here");
931
932        let err = generate_from_directory(&abi_dir, &output_dir).unwrap_err();
933        assert!(matches!(err, AptosError::Config(ref m) if m.contains("No JSON files found")));
934    }
935
936    #[test]
937    fn test_generate_from_directory_empty_dir() {
938        let temp_dir = TempDir::new().unwrap();
939        let abi_dir = temp_dir.path().join("abi");
940        let output_dir = temp_dir.path().join("generated");
941        fs::create_dir_all(&abi_dir).unwrap();
942
943        let err = generate_from_directory(&abi_dir, &output_dir).unwrap_err();
944        assert!(matches!(err, AptosError::Config(ref m) if m.contains("No JSON files found")));
945    }
946
947    #[test]
948    fn test_generate_from_directory_unreadable_dir() {
949        let temp_dir = TempDir::new().unwrap();
950        // Point at a path that does not exist -> read_dir fails.
951        let abi_dir = temp_dir.path().join("nonexistent");
952        let output_dir = temp_dir.path().join("generated");
953
954        let err = generate_from_directory(&abi_dir, &output_dir).unwrap_err();
955        assert!(
956            matches!(err, AptosError::Config(ref m) if m.contains("Failed to read ABI directory"))
957        );
958    }
959
960    // --- generate_mod_file -------------------------------------------------
961
962    #[test]
963    fn test_generate_mod_file_header_and_reexports() {
964        let modules = vec!["alpha".to_string()];
965        let content = generate_mod_file(&modules);
966        assert!(content.contains("Auto-generated module exports"));
967        assert!(content.contains("Do not edit manually"));
968        assert!(content.contains("pub mod alpha;"));
969        assert!(content.contains("pub use alpha::*;"));
970    }
971}