Skip to main content

sails_cli/
solgen.rs

1use crate::utils::{self, OutputExt};
2use anyhow::{Context, Result, bail};
3use askama::Template;
4use chrono::{Datelike, Utc};
5use convert_case::{Case, Casing};
6use sails_sol_gen::{LICENSE_IDENTIFIER, SOLIDITY_VERSION, SolidityFile};
7use std::{
8    env,
9    fmt::Display,
10    fs::{self, File},
11    io::Write,
12    path::{Path, PathBuf},
13    process::{Command, Stdio},
14};
15
16const FORGE_STD_VERSION: &str = "1.16.2";
17const EVM_VERSION: &str = "cancun";
18const ICON_CONFIG: &str = "📋";
19const ICON_WORKSPACE: &str = "📜";
20const ICON_DEPENDENCIES: &str = "📦";
21const ICON_FORMAT: &str = "✨";
22const ICON_DONE: &str = "✅";
23
24#[derive(Template)]
25#[template(path = "forge/.github/workflows/test.askama")]
26struct TestWorkflow {
27    git_branch_name: String,
28}
29
30#[derive(Template)]
31#[template(path = "forge/vscode/settings.askama")]
32struct VsCodeSettings {
33    solidity_version: String,
34}
35
36// TODO: script, src, test
37
38#[derive(Template)]
39#[template(path = "forge/script/AbiScript.askama")]
40struct AbiScript {
41    license_identifier: String,
42    solidity_version: String,
43    contract_name: String,
44    contract_name_camel_case: String,
45}
46
47#[derive(Template)]
48#[template(path = "forge/script/CallerScript.askama")]
49struct CallerScript {
50    license_identifier: String,
51    solidity_version: String,
52    contract_name: String,
53    contract_name_camel_case: String,
54}
55
56#[derive(Template)]
57#[template(path = "forge/test/AbiTest.askama")]
58struct AbiTest {
59    license_identifier: String,
60    solidity_version: String,
61    contract_name: String,
62    contract_name_camel_case: String,
63}
64
65#[derive(Template)]
66#[template(path = "forge/test/CallerTest.askama")]
67struct CallerTest {
68    license_identifier: String,
69    solidity_version: String,
70    contract_name: String,
71    contract_name_camel_case: String,
72}
73
74#[derive(Template)]
75#[template(path = "forge/env_example.askama")]
76struct RootEnvExample {
77    contract_name: String,
78}
79
80#[derive(Template)]
81#[template(path = "forge/foundry.askama")]
82struct RootFoundry {
83    forge_std_version: String,
84    evm_version: String,
85    solidity_version: String,
86}
87
88#[derive(Template)]
89#[template(path = "forge/gitignore.askama")]
90struct RootGitignore;
91
92#[derive(Template)]
93#[template(path = "forge/license.askama")]
94struct RootLicense {
95    copyright_year: String,
96    package_author: String,
97}
98
99#[derive(Template)]
100#[template(path = "forge/readme.askama")]
101struct RootReadme {
102    contract_name: String,
103    contract_name_kebab_case: String,
104    github_username: String,
105}
106
107#[derive(Template)]
108#[template(path = "forge/remappings.askama")]
109struct RootRemappings {
110    forge_std_version: String,
111}
112
113pub enum SolidityGeneratorOutputType {
114    SingleSolidity,
115    ForgeProject,
116}
117
118pub struct SolidityGenerator {
119    idl_path: PathBuf,
120    target_dir: PathBuf,
121    contract_name: Option<String>,
122    package_author: String,
123    github_username: String,
124    offline: bool,
125    output_type: SolidityGeneratorOutputType,
126}
127
128impl SolidityGenerator {
129    const DEFAULT_AUTHOR: &str = "Gear Technologies";
130    const DEFAULT_GITHUB_USERNAME: &str = "gear-tech";
131
132    pub fn new(
133        idl_path: PathBuf,
134        target_dir: Option<PathBuf>,
135        contract_name: Option<String>,
136        author: Option<String>,
137        username: Option<String>,
138        offline: bool,
139        output_type: SolidityGeneratorOutputType,
140    ) -> Self {
141        let package_author = author.unwrap_or_else(|| Self::DEFAULT_AUTHOR.to_string());
142        let github_username = username.unwrap_or_else(|| Self::DEFAULT_GITHUB_USERNAME.to_string());
143        Self {
144            idl_path,
145            target_dir: target_dir.unwrap_or_default(),
146            contract_name,
147            package_author,
148            github_username,
149            offline,
150            output_type,
151        }
152    }
153
154    fn test_workflow(&self, git_branch_name: &str) -> TestWorkflow {
155        TestWorkflow {
156            git_branch_name: git_branch_name.into(),
157        }
158    }
159
160    fn vscode_settings(&self) -> VsCodeSettings {
161        VsCodeSettings {
162            solidity_version: SOLIDITY_VERSION.into(),
163        }
164    }
165
166    // TODO: script, src, test
167
168    fn abi_script(&self, contract_name: &str, contract_name_camel_case: &str) -> AbiScript {
169        AbiScript {
170            license_identifier: LICENSE_IDENTIFIER.into(),
171            solidity_version: SOLIDITY_VERSION.into(),
172            contract_name: contract_name.into(),
173            contract_name_camel_case: contract_name_camel_case.into(),
174        }
175    }
176
177    fn caller_script(&self, contract_name: &str, contract_name_camel_case: &str) -> CallerScript {
178        CallerScript {
179            license_identifier: LICENSE_IDENTIFIER.into(),
180            solidity_version: SOLIDITY_VERSION.into(),
181            contract_name: contract_name.into(),
182            contract_name_camel_case: contract_name_camel_case.into(),
183        }
184    }
185
186    fn abi_test(&self, contract_name: &str, contract_name_camel_case: &str) -> AbiTest {
187        AbiTest {
188            license_identifier: LICENSE_IDENTIFIER.into(),
189            solidity_version: SOLIDITY_VERSION.into(),
190            contract_name: contract_name.into(),
191            contract_name_camel_case: contract_name_camel_case.into(),
192        }
193    }
194
195    fn caller_test(&self, contract_name: &str, contract_name_camel_case: &str) -> CallerTest {
196        CallerTest {
197            license_identifier: LICENSE_IDENTIFIER.into(),
198            solidity_version: SOLIDITY_VERSION.into(),
199            contract_name: contract_name.into(),
200            contract_name_camel_case: contract_name_camel_case.into(),
201        }
202    }
203
204    fn root_env_example(&self, contract_name: &str) -> RootEnvExample {
205        RootEnvExample {
206            contract_name: contract_name.into(),
207        }
208    }
209
210    fn root_foundry(&self) -> RootFoundry {
211        RootFoundry {
212            forge_std_version: FORGE_STD_VERSION.into(),
213            evm_version: EVM_VERSION.into(),
214            solidity_version: SOLIDITY_VERSION.into(),
215        }
216    }
217
218    fn root_gitignore(&self) -> RootGitignore {
219        RootGitignore
220    }
221
222    fn root_license(&self) -> RootLicense {
223        RootLicense {
224            copyright_year: Utc::now().year().to_string(),
225            package_author: self.package_author.clone(),
226        }
227    }
228
229    fn root_readme(&self, contract_name: &str, contract_name_kebab_case: &str) -> RootReadme {
230        RootReadme {
231            contract_name: contract_name.into(),
232            contract_name_kebab_case: contract_name_kebab_case.into(),
233            github_username: self.github_username.clone(),
234        }
235    }
236
237    fn root_remappings(&self) -> RootRemappings {
238        RootRemappings {
239            forge_std_version: FORGE_STD_VERSION.into(),
240        }
241    }
242
243    pub fn generate(self) -> Result<()> {
244        let idl_content = fs::read_to_string(&self.idl_path)?;
245        let filename: String = if let Some(stem) = self.idl_path.file_stem() {
246            stem.to_string_lossy().into()
247        } else {
248            bail!("No filename found");
249        };
250        let contract_name = self.contract_name.clone().unwrap_or(filename.to_string());
251
252        println!("⛵ Creating bindings for Solidity...");
253        self.print_config(&contract_name);
254
255        match self.output_type {
256            SolidityGeneratorOutputType::SingleSolidity => {
257                self.generate_single_solidity(&idl_content, &contract_name.to_case(Case::Pascal))?
258            }
259            SolidityGeneratorOutputType::ForgeProject => {
260                self.generate_forge_project(&idl_content, &contract_name.to_case(Case::Kebab))?
261            }
262        }
263
264        Ok(())
265    }
266
267    fn print_config(&self, contract_name: &str) {
268        let print_field = |label: &str, value: &dyn Display| {
269            println!("   {label:<10} {value}");
270        };
271
272        println!("{ICON_CONFIG} Solidity config:");
273        print_field("idl:", &self.idl_path.display());
274        if self.target_dir.as_os_str().is_empty() {
275            print_field("path:", &"none");
276        } else {
277            print_field("path:", &self.target_dir.display());
278        }
279        print_field("contract:", &contract_name);
280        print_field("author:", &self.package_author);
281        print_field("username:", &self.github_username);
282        print_field("offline:", &self.offline);
283        match self.output_type {
284            SolidityGeneratorOutputType::SingleSolidity => {
285                print_field("output:", &"single Solidity file");
286            }
287            SolidityGeneratorOutputType::ForgeProject => {
288                print_field("output:", &"Forge project");
289            }
290        }
291    }
292
293    fn generate_single_solidity(&self, idl_content: &str, contract_name: &str) -> Result<()> {
294        let data = sails_sol_gen::generate_solidity_contract(
295            contract_name,
296            idl_content,
297            SolidityFile::SingleFile,
298        )?;
299        let target_file = self.target_dir.join(format!("{contract_name}.sol"));
300
301        fs::write(&target_file, data)?;
302
303        println!(
304            "Generated single Solidity file: {target_file}",
305            target_file = target_file.display()
306        );
307
308        Ok(())
309    }
310
311    fn generate_forge_project(
312        &self,
313        idl_content: &str,
314        contract_name_kebab_case: &str,
315    ) -> Result<()> {
316        println!("{ICON_WORKSPACE} [1/3] Initializing workspace...");
317        let project_dir = self.generate_root(idl_content, contract_name_kebab_case)?;
318
319        println!("{ICON_DEPENDENCIES} [2/3] Installing dependencies...");
320        self.install(&project_dir)?;
321
322        println!("{ICON_FORMAT} [3/3] Formatting workspace...");
323        self.fmt(&project_dir)?;
324
325        println!("{ICON_DONE} Done.");
326
327        Ok(())
328    }
329
330    fn generate_root(&self, idl_content: &str, contract_name_kebab_case: &str) -> Result<PathBuf> {
331        let contract_name_pascal_case = contract_name_kebab_case.to_case(Case::Pascal);
332        let contract_name_camel_case = contract_name_kebab_case.to_case(Case::Camel);
333
334        let project_dir = self.target_dir.join(contract_name_kebab_case);
335        if project_dir.exists() {
336            bail!("Target directory already exists: {}", project_dir.display());
337        }
338        fs::create_dir_all(&project_dir)?;
339
340        let project_dir = project_dir
341            .canonicalize()
342            .context("Failed to canonicalize project directory")?;
343        let path = &project_dir;
344
345        git_init(path)?;
346
347        let git_branch_name = utils::git_show_current_branch(path)?;
348        println!("   git branch: {git_branch_name}");
349
350        fs::create_dir_all(test_workflow_dir_path(path))?;
351        let mut test_workflow_yml = File::create(test_workflow_path(path))?;
352        self.test_workflow(&git_branch_name)
353            .write_into(&mut test_workflow_yml)?;
354
355        fs::create_dir_all(vscode_dir_path(path))?;
356        let mut vscode_settings_json = File::create(vscode_settings_path(path))?;
357        self.vscode_settings()
358            .write_into(&mut vscode_settings_json)?;
359
360        // TODO: script, src, test
361
362        fs::create_dir_all(script_dir_path(path))?;
363
364        let mut abi_script_sol =
365            File::create(abi_script_sol_path(path, &contract_name_pascal_case))?;
366        self.abi_script(&contract_name_pascal_case, &contract_name_camel_case)
367            .write_into(&mut abi_script_sol)?;
368
369        let mut caller_script_sol =
370            File::create(caller_script_sol_path(path, &contract_name_pascal_case))?;
371        self.caller_script(&contract_name_pascal_case, &contract_name_camel_case)
372            .write_into(&mut caller_script_sol)?;
373
374        fs::create_dir_all(src_dir_path(path))?;
375
376        let mut solidity_interface_file = File::create(solidity_interface_file_path(
377            path,
378            &contract_name_pascal_case,
379        ))?;
380        let solidity_interface_file_content = sails_sol_gen::generate_solidity_contract(
381            &contract_name_pascal_case,
382            idl_content,
383            SolidityFile::InterfaceFile,
384        )?;
385        solidity_interface_file.write_all(&solidity_interface_file_content)?;
386
387        let mut solidity_abi_interface_file = File::create(solidity_abi_interface_file_path(
388            path,
389            &contract_name_pascal_case,
390        ))?;
391        let solidity_abi_interface_file_content = sails_sol_gen::generate_solidity_contract(
392            &contract_name_pascal_case,
393            idl_content,
394            SolidityFile::AbiInterfaceFile,
395        )?;
396        solidity_abi_interface_file.write_all(&solidity_abi_interface_file_content)?;
397
398        let mut solidity_callbacks_interface_file = File::create(
399            solidity_callbacks_interface_file_path(path, &contract_name_pascal_case),
400        )?;
401        let solidity_callbacks_interface_file_content = sails_sol_gen::generate_solidity_contract(
402            &contract_name_pascal_case,
403            idl_content,
404            SolidityFile::CallbacksInterfaceFile,
405        )?;
406        solidity_callbacks_interface_file.write_all(&solidity_callbacks_interface_file_content)?;
407
408        let mut solidity_caller_file =
409            File::create(solidity_caller_file_path(path, &contract_name_pascal_case))?;
410        let solidity_caller_file_content = sails_sol_gen::generate_solidity_contract(
411            &contract_name_pascal_case,
412            idl_content,
413            SolidityFile::CallerFile,
414        )?;
415        solidity_caller_file.write_all(&solidity_caller_file_content)?;
416
417        fs::create_dir_all(test_dir_path(path))?;
418
419        let mut test_abi_test_sol =
420            File::create(test_abi_test_sol_path(path, &contract_name_pascal_case))?;
421        self.abi_test(&contract_name_pascal_case, &contract_name_camel_case)
422            .write_into(&mut test_abi_test_sol)?;
423
424        let mut test_caller_test_sol =
425            File::create(test_caller_test_sol_path(path, &contract_name_pascal_case))?;
426        self.caller_test(&contract_name_pascal_case, &contract_name_camel_case)
427            .write_into(&mut test_caller_test_sol)?;
428
429        let mut root_env_example = File::create(env_example_path(path))?;
430        self.root_env_example(&contract_name_pascal_case)
431            .write_into(&mut root_env_example)?;
432
433        let mut foundry_toml = File::create(foundry_path(path))?;
434        self.root_foundry().write_into(&mut foundry_toml)?;
435
436        let mut gitignore = File::create(gitignore_path(path))?;
437        self.root_gitignore().write_into(&mut gitignore)?;
438
439        let mut license = File::create(license_path(path))?;
440        self.root_license().write_into(&mut license)?;
441
442        let mut readme = File::create(readme_path(path))?;
443        self.root_readme(&contract_name_pascal_case, contract_name_kebab_case)
444            .write_into(&mut readme)?;
445
446        let mut remappings = File::create(remappings_path(path))?;
447        self.root_remappings().write_into(&mut remappings)?;
448
449        Ok(project_dir)
450    }
451
452    fn install<P: AsRef<Path>>(&self, project_dir: P) -> Result<()> {
453        let path = &project_dir;
454
455        if !self.offline {
456            forge_soldeer_install(path)?;
457        }
458
459        Ok(())
460    }
461
462    fn fmt<P: AsRef<Path>>(&self, project_dir: P) -> Result<()> {
463        let path = &project_dir;
464        forge_fmt(path)
465    }
466}
467
468fn forge_soldeer_install<P: AsRef<Path>>(dir: P) -> Result<()> {
469    let forge_command = forge_command();
470    let mut cmd = Command::new(forge_command);
471    cmd.current_dir(dir)
472        .stdout(Stdio::piped())
473        .arg("soldeer")
474        .arg("install");
475
476    let _ = cmd
477        .output()?
478        .exit_result()
479        .context("failed to install soldeer packages")?;
480
481    Ok(())
482}
483
484fn forge_fmt<P: AsRef<Path>>(dir: P) -> Result<()> {
485    let forge_command = forge_command();
486    let mut cmd = Command::new(forge_command);
487    cmd.current_dir(dir).stdout(Stdio::piped()).arg("fmt");
488
489    let _ = cmd
490        .output()?
491        .exit_result()
492        .context("failed to format the project")?;
493
494    Ok(())
495}
496
497fn git_init<P: AsRef<Path>>(target_dir: P) -> Result<()> {
498    let git_command = utils::git_command();
499    let mut cmd = Command::new(git_command);
500    cmd.stdout(Stdio::piped())
501        .arg("-C")
502        .arg(target_dir.as_ref())
503        .arg("init");
504
505    let _ = cmd
506        .output()?
507        .exit_result()
508        .context("failed to initialize git repository")?;
509
510    Ok(())
511}
512
513fn test_workflow_dir_path<P: AsRef<Path>>(path: P) -> PathBuf {
514    path.as_ref().join(".github/workflows")
515}
516
517fn test_workflow_path<P: AsRef<Path>>(path: P) -> PathBuf {
518    path.as_ref().join(".github/workflows/test.yml")
519}
520
521fn vscode_dir_path<P: AsRef<Path>>(path: P) -> PathBuf {
522    path.as_ref().join(".vscode")
523}
524
525fn vscode_settings_path<P: AsRef<Path>>(path: P) -> PathBuf {
526    path.as_ref().join(".vscode/settings.json")
527}
528
529// TODO: script, src, test
530
531fn script_dir_path<P: AsRef<Path>>(path: P) -> PathBuf {
532    path.as_ref().join("script")
533}
534
535fn abi_script_sol_path<P: AsRef<Path>>(path: P, contract_name: &str) -> PathBuf {
536    path.as_ref()
537        .join("script")
538        .join(format!("{contract_name}Abi.s.sol"))
539}
540
541fn caller_script_sol_path<P: AsRef<Path>>(path: P, contract_name: &str) -> PathBuf {
542    path.as_ref()
543        .join("script")
544        .join(format!("{contract_name}Caller.s.sol"))
545}
546
547fn src_dir_path<P: AsRef<Path>>(path: P) -> PathBuf {
548    path.as_ref().join("src")
549}
550
551fn solidity_interface_file_path<P: AsRef<Path>>(path: P, contract_name: &str) -> PathBuf {
552    path.as_ref()
553        .join("src")
554        .join(format!("I{contract_name}.sol"))
555}
556
557fn solidity_abi_interface_file_path<P: AsRef<Path>>(path: P, contract_name: &str) -> PathBuf {
558    path.as_ref()
559        .join("src")
560        .join(format!("{contract_name}Abi.sol"))
561}
562
563fn solidity_callbacks_interface_file_path<P: AsRef<Path>>(path: P, contract_name: &str) -> PathBuf {
564    path.as_ref()
565        .join("src")
566        .join(format!("I{contract_name}Callbacks.sol"))
567}
568
569fn solidity_caller_file_path<P: AsRef<Path>>(path: P, contract_name: &str) -> PathBuf {
570    path.as_ref()
571        .join("src")
572        .join(format!("{contract_name}Caller.sol"))
573}
574
575fn test_dir_path<P: AsRef<Path>>(path: P) -> PathBuf {
576    path.as_ref().join("test")
577}
578
579fn test_abi_test_sol_path<P: AsRef<Path>>(path: P, contract_name: &str) -> PathBuf {
580    path.as_ref()
581        .join("test")
582        .join(format!("{contract_name}Abi.t.sol"))
583}
584
585fn test_caller_test_sol_path<P: AsRef<Path>>(path: P, contract_name: &str) -> PathBuf {
586    path.as_ref()
587        .join("test")
588        .join(format!("{contract_name}Caller.t.sol"))
589}
590
591fn env_example_path<P: AsRef<Path>>(path: P) -> PathBuf {
592    path.as_ref().join(".env.example")
593}
594
595fn foundry_path<P: AsRef<Path>>(path: P) -> PathBuf {
596    path.as_ref().join("foundry.toml")
597}
598
599fn gitignore_path<P: AsRef<Path>>(path: P) -> PathBuf {
600    path.as_ref().join(".gitignore")
601}
602
603fn license_path<P: AsRef<Path>>(path: P) -> PathBuf {
604    path.as_ref().join("LICENSE")
605}
606
607fn readme_path<P: AsRef<Path>>(path: P) -> PathBuf {
608    path.as_ref().join("README.md")
609}
610
611fn remappings_path<P: AsRef<Path>>(path: P) -> PathBuf {
612    path.as_ref().join("remappings.txt")
613}
614
615fn forge_command() -> String {
616    env::var("FORGE").unwrap_or("forge".into())
617}