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#[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 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 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
529fn 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}