Repository: j178/leetgo (language: python)

Issue:
Rust local test support



Discussion / hints:
I would like to help to support Rust. I will take the job in following days.

Fix patch:
diff --git a/README.md b/README.md
index 88acab7f..d75f16b1 100644
--- a/README.md
+++ b/README.md
@@ -66,7 +66,7 @@ Local testing requires more work to implement for each language, so not all lang
 | Go | :white_check_mark: | :white_check_mark: |
 | Python | :white_check_mark: | Not yet |
 | C++ | :white_check_mark: | :white_check_mark: |
-| Rust | :white_check_mark: | Not yet |
+| Rust | :white_check_mark: | :white_check_mark: |
 | Java | :white_check_mark: | Not yet |
 | JavaScript | :white_check_mark: | Not yet |
 | TypeScript | :white_check_mark: | Not yet |
@@ -212,14 +212,14 @@ code:
     cxxflags:
       - -O2
       - -std=c++17
-  java:
-    out_dir: java
-    # Overrides the default code.filename_template
-    filename_template: ""
   rust:
     out_dir: rust
     # Overrides the default code.filename_template
     filename_template: ""
+  java:
+    out_dir: java
+    # Overrides the default code.filename_template
+    filename_template: ""
 # LeetCode configuration
 leetcode:
   # LeetCode site, https://leetcode.com or https://leetcode.cn
diff --git a/README_zh.md b/README_zh.md
index e9bc8c9e..a1924296 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -65,7 +65,7 @@ func main() {
 | Go | :white_check_mark: | :white_check_mark: |
 | Python | :white_check_mark: | Not yet |
 | C++ | :white_check_mark: | :white_check_mark: |
-| Rust | :white_check_mark: | Not yet |
+| Rust | :white_check_mark: | :white_check_mark: |
 | Java | :white_check_mark: | Not yet |
 | JavaScript | :white_check_mark: | Not yet |
 | TypeScript | :white_check_mark: | Not yet |
@@ -206,14 +206,14 @@ code:
     cxxflags:
       - -O2
       - -std=c++17
-  java:
-    out_dir: java
-    # Overrides the default code.filename_template
-    filename_template: ""
   rust:
     out_dir: rust
     # Overrides the default code.filename_template
     filename_template: ""
+  java:
+    out_dir: java
+    # Overrides the default code.filename_template
+    filename_template: ""
 # LeetCode configuration
 leetcode:
   # LeetCode site, https://leetcode.com or https://leetcode.cn
diff --git a/config/config.go b/config/config.go
index e4847162..7007496b 100644
--- a/config/config.go
+++ b/config/config.go
@@ -8,11 +8,12 @@ import (
 	"strings"
 
 	"github.com/charmbracelet/log"
-	"github.com/j178/leetgo/constants"
 	"github.com/mitchellh/go-homedir"
 	"github.com/spf13/viper"
 	"gopkg.in/yaml.v3"
 
+	"github.com/j178/leetgo/constants"
+
 	"github.com/j178/leetgo/utils"
 )
 
@@ -75,8 +76,8 @@ type CodeConfig struct {
 	Go                      GoConfig       `yaml:"go" mapstructure:"go"`
 	Python                  BaseLangConfig `yaml:"python3" mapstructure:"python3"`
 	Cpp                     CppConfig      `yaml:"cpp" mapstructure:"cpp"`
+	Rust                    RustConfig     `yaml:"rust" mapstructure:"rust"`
 	Java                    BaseLangConfig `yaml:"java" mapstructure:"java"`
-	Rust                    BaseLangConfig `yaml:"rust" mapstructure:"rust"`
 	// Add more languages here
 }
 
@@ -98,6 +99,10 @@ type CppConfig struct {
 	CXXFLAGS       []string `yaml:"cxxflags" mapstructure:"cxxflags" comment:"C++ compiler flags (our Leetcode I/O library implementation requires C++17)"`
 }
 
+type RustConfig struct {
+	BaseLangConfig `yaml:",inline" mapstructure:",squash"`
+}
+
 type Credentials struct {
 	From      string `yaml:"from" mapstructure:"from" comment:"How to provide credentials: browser, cookies, password or none"`
 	Session   string `yaml:"session" mapstructure:"session" comment:"LeetCode cookie: LEETCODE_SESSION"`
@@ -206,7 +211,7 @@ func Default() *Config {
 			},
 			Python: BaseLangConfig{OutDir: "python"},
 			Java:   BaseLangConfig{OutDir: "java"},
-			Rust:   BaseLangConfig{OutDir: "rust"},
+			Rust:   RustConfig{BaseLangConfig: BaseLangConfig{OutDir: "rust"}},
 			// Add more languages here
 		},
 		LeetCode: LeetCodeConfig{
diff --git a/constants/constants.go b/constants/constants.go
index fcd3e548..53745114 100644
--- a/constants/constants.go
+++ b/constants/constants.go
@@ -14,6 +14,7 @@ const (
 	StateFilename         = "state.json"
 	CodeBeginMarker       = "@lc code=begin"
 	CodeEndMarker         = "@lc code=end"
-	GoTestUtilsModPath    = "github.com/j178/leetgo/testutils/go"
 	ProjectURL            = "https://github.com/j178/leetgo"
+	GoTestUtilsModPath    = "github.com/j178/leetgo/testutils/go"
+	RustTestUtilsCrate    = "leetgo_rs"
 )
diff --git a/lang/base.go b/lang/base.go
index bca0d43a..75e6267c 100644
--- a/lang/base.go
+++ b/lang/base.go
@@ -26,12 +26,13 @@ const (
 )
 
 type GenerateResult struct {
-	Question *leetcode.QuestionData
-	Lang     Lang
-	OutDir   string
-	SubDir   string
-	Files    []FileOutput
-	mask     int
+	mask        int
+	Question    *leetcode.QuestionData
+	Lang        Lang
+	OutDir      string
+	SubDir      string
+	Files       []FileOutput
+	ResultHooks []func(*GenerateResult) error
 }
 
 type FileOutput struct {
diff --git a/lang/gen.go b/lang/gen.go
index 77548dd8..b13a60a8 100644
--- a/lang/gen.go
+++ b/lang/gen.go
@@ -82,6 +82,14 @@ func generate(q *leetcode.QuestionData) (Lang, *GenerateResult, error) {
 		return nil, nil, err
 	}
 	result.SetOutDir(outDir)
+
+	for _, hook := range result.ResultHooks {
+		err := hook(result)
+		if err != nil {
+			return nil, nil, err
+		}
+	}
+
 	// Write files
 	for i, file := range result.Files {
 		written, err := tryWrite(file.GetPath(), file.Content)
diff --git a/lang/go.go b/lang/go.go
index 691c7121..b752919c 100644
--- a/lang/go.go
+++ b/lang/go.go
@@ -117,7 +117,7 @@ func (g golang) HasInitialized(outDir string) (bool, error) {
 }
 
 func (g golang) Initialize(outDir string) error {
-	modPath := "leetcode-solutions"
+	const modPath = "leetcode-solutions"
 	var stderr bytes.Buffer
 	cmd := exec.Command("go", "mod", "init", modPath)
 	cmd.Dir = outDir
@@ -162,8 +162,8 @@ func (g golang) RunLocalTest(q *leetcode.QuestionData, outDir string) (bool, err
 	return runTest(q, genResult, []string{execFile}, outDir)
 }
 
-// convertToGoType converts LeetCode type name to Go type name.
-func convertToGoType(typeName string) string {
+// toGoType converts LeetCode type name to Go type name.
+func toGoType(typeName string) string {
 	switch typeName {
 	case "integer":
 		return "int"
@@ -183,7 +183,7 @@ func convertToGoType(typeName string) string {
 		return "*ListNode"
 	default:
 		if strings.HasSuffix(typeName, "[]") {
-			return "[]" + convertToGoType(typeName[:len(typeName)-2])
+			return "[]" + toGoType(typeName[:len(typeName)-2])
 		}
 	}
 	return typeName
@@ -201,7 +201,7 @@ func (g golang) generateNormalTestCode(q *leetcode.QuestionData) (string, error)
 		code += fmt.Sprintf(
 			"\t%s := Deserialize[%s](ReadLine(stdin))\n",
 			param.Name,
-			convertToGoType(param.Type),
+			toGoType(param.Type),
 		)
 		paramNames = append(paramNames, param.Name)
 	}
@@ -260,7 +260,7 @@ func (g golang) generateSystemDesignTestCode(q *leetcode.QuestionData) (string,
 			prepareCode += fmt.Sprintf(
 				"\t%s := Deserialize[%s](constructorParams[%d])\n",
 				param.Name,
-				convertToGoType(param.Type),
+				toGoType(param.Type),
 				i,
 			)
 			paramNames = append(paramNames, param.Name)
@@ -279,7 +279,7 @@ func (g golang) generateSystemDesignTestCode(q *leetcode.QuestionData) (string,
 			methodCall += fmt.Sprintf(
 				"\t\t\t%s := Deserialize[%s](methodParams[%d])\n",
 				param.Name,
-				convertToGoType(param.Type),
+				toGoType(param.Type),
 				i,
 			)
 			methodParamNames = append(methodParamNames, param.Name)
diff --git a/lang/list.go b/lang/list.go
index 2d3e9508..6565d165 100644
--- a/lang/list.go
+++ b/lang/list.go
@@ -34,14 +34,16 @@ var (
 			blockCommentEnd:   "*/",
 		},
 	}
-	rustGen = baseLang{
-		name:              "Rust",
-		slug:              "rust",
-		shortName:         "rs",
-		extension:         ".rs",
-		lineComment:       "//",
-		blockCommentStart: "/*",
-		blockCommentEnd:   "*/",
+	rustGen = rust{
+		baseLang{
+			name:              "Rust",
+			slug:              "rust",
+			shortName:         "rs",
+			extension:         ".rs",
+			lineComment:       "//",
+			blockCommentStart: "/*",
+			blockCommentEnd:   "*/",
+		},
 	}
 	javaGen = baseLang{
 		name:              "Java",
diff --git a/lang/rust.go b/lang/rust.go
new file mode 100644
index 00000000..12efb6e5
--- /dev/null
+++ b/lang/rust.go
@@ -0,0 +1,467 @@
+package lang
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"sort"
+	"strings"
+	"unicode"
+
+	"github.com/charmbracelet/log"
+	"github.com/pelletier/go-toml/v2"
+
+	"github.com/j178/leetgo/config"
+	"github.com/j178/leetgo/constants"
+	"github.com/j178/leetgo/leetcode"
+	"github.com/j178/leetgo/utils"
+)
+
+type rust struct {
+	baseLang
+}
+
+func (r rust) HasInitialized(outDir string) (bool, error) {
+	if !utils.IsExist(filepath.Join(outDir, "Cargo.toml")) {
+		return false, nil
+	}
+	return true, nil
+}
+
+func (r rust) Initialize(outDir string) error {
+	const packageName = "leetcode-solutions"
+	cmd := exec.Command("cargo", "init", "--bin", "--name", packageName, outDir)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	cmd.Dir = outDir
+	err := cmd.Run()
+	if err != nil {
+		return err
+	}
+
+	cmd = exec.Command("cargo", "add", "serde", "serde_json", "anyhow", constants.RustTestUtilsCrate)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	cmd.Dir = outDir
+	err = cmd.Run()
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+func (r rust) RunLocalTest(q *leetcode.QuestionData, outDir string) (bool, error) {
+	genResult, err := r.GeneratePaths(q)
+	if err != nil {
+		return false, fmt.Errorf("generate paths failed: %w", err)
+	}
+	genResult.SetOutDir(outDir)
+
+	build := exec.Command("cargo", "build", "--bin", q.TitleSlug)
+	build.Dir = outDir
+	build.Stdout = os.Stdout
+	build.Stderr = os.Stderr
+	log.Info("building", "cmd", build.String())
+	err = build.Run()
+	if err != nil {
+		return false, fmt.Errorf("build failed: %w", err)
+	}
+
+	return runTest(q, genResult, []string{"cargo", "run", "--bin", q.TitleSlug}, outDir)
+}
+
+func toRustType(typeName string) string {
+	switch typeName {
+	case "integer":
+		return "i32"
+	case "string":
+		return "String"
+	case "long":
+		return "i64"
+	case "double":
+		return "f64"
+	case "boolean":
+		return "bool"
+	case "character":
+		return "char"
+	case "void":
+		return "!"
+	case "TreeNode":
+		return "BinaryTree"
+	case "ListNode":
+		return "LinkedList"
+	default:
+		if strings.HasSuffix(typeName, "[]") {
+			return "Vec<" + toRustType(typeName[:len(typeName)-2]) + ">"
+		}
+	}
+	return typeName
+}
+
+func toRustVarName(name string) string {
+	var snakeStrBuilder strings.Builder
+
+	for i, r := range name {
+		if i > 0 && unicode.IsUpper(r) && !unicode.IsUpper([]rune(name)[i-1]) {
+			snakeStrBuilder.WriteRune('_')
+		}
+		snakeStrBuilder.WriteRune(unicode.ToLower(r))
+	}
+
+	return snakeStrBuilder.String()
+}
+
+func formatCallArgs(argTypes, args []string) string {
+	if len(args) == 0 {
+		return ""
+	}
+	res := make([]string, 0, len(args))
+	for i, arg := range args {
+		if argTypes[i] == "BinaryTree" || argTypes[i] == "LinkedList" {
+			res = append(res, arg+".into()")
+		} else {
+			res = append(res, arg)
+		}
+	}
+	return strings.Join(res, ", ")
+}
+
+func (r rust) generateNormalTestCode(q *leetcode.QuestionData) (string, error) {
+	const template = `fn main() -> Result<()> {
+%s
+	Ok(())
+}`
+	code := ""
+	paramTypes := make([]string, 0, len(q.MetaData.Params))
+	paramNames := make([]string, 0, len(q.MetaData.Params))
+	for _, param := range q.MetaData.Params {
+		varName := toRustVarName(param.Name)
+		varType := toRustType(param.Type)
+		code += fmt.Sprintf(
+			"\tlet %s: %s = deserialize(&read_line()?)?;\n",
+			varName,
+			varType,
+		)
+		paramNames = append(paramNames, varName)
+		paramTypes = append(paramTypes, varType)
+	}
+
+	if q.MetaData.Return != nil && q.MetaData.Return.Type != "void" {
+		code += fmt.Sprintf(
+			"\tlet ans = Solution::%s(%s);\n",
+			toRustVarName(q.MetaData.Name),
+			formatCallArgs(paramTypes, paramNames),
+		)
+	} else {
+		// TODO: input param should be mut ref
+		code += fmt.Sprintf(
+			"\tSolution::%s(%s);\n",
+			toRustVarName(q.MetaData.Name),
+			formatCallArgs(paramTypes, paramNames),
+		)
+		ansName := paramNames[q.MetaData.Output.ParamIndex]
+		code += fmt.Sprintf("\tlet ans = %s;\n", ansName)
+	}
+	code += fmt.Sprintf(
+		"\n\tprintln!(\"%s {}\", serialize(ans)?);",
+		testCaseOutputMark,
+	)
+
+	testContent := fmt.Sprintf(template, code)
+	return testContent, nil
+}
+
+func (r rust) generateSystemDesignTestCode(q *leetcode.QuestionData) (string, error) {
+	const template = `fn main() -> Result<()> {
+	let ops: Vec<String> = deserialize(&read_line()?)?;
+	let params = split_array(&read_line()?)?;
+	let mut output = Vec::with_capacity(ops.len());
+	output.push("null".to_string());
+
+%s
+
+	for i in 1..ops.len() {
+		match ops[i].as_str() {
+%s
+			_ => panic!("unknown op"),
+		}
+	}
+
+	println!("%s {}", join_array(output));
+	Ok(())
+}
+`
+	var prepareCode string
+	var paramNames []string
+	if len(q.MetaData.Constructor.Params) > 0 {
+		prepareCode += "\tlet constructor_params = split_array(&params[0])?;\n"
+		for i, param := range q.MetaData.Constructor.Params {
+			prepareCode += fmt.Sprintf(
+				"\tlet %s: %s = deserialize(&constructor_params[%d])?;\n",
+				toRustVarName(param.Name),
+				toRustType(param.Type),
+				i,
+			)
+			paramNames = append(paramNames, toRustVarName(param.Name))
+		}
+	}
+	prepareCode += fmt.Sprintf(
+		"\t#[warn(unused_mut)]\n\tlet mut obj = %s::new(%s);",
+		q.MetaData.ClassName,
+		strings.Join(paramNames, ", "),
+	)
+
+	callCode := ""
+	for _, method := range q.MetaData.Methods {
+		methodCall := fmt.Sprintf("\t\t\t\"%s\" => {\n", method.Name)
+		if len(method.Params) > 0 {
+			methodCall += "\t\t\t\tlet method_params = split_array(&params[i])?;\n"
+		}
+		methodParamNames := make([]string, 0, len(method.Params))
+		methodParamTypes := make([]string, 0, len(method.Params))
+		for i, param := range method.Params {
+			varName := toRustVarName(param.Name)
+			varType := toRustType(param.Type)
+			methodCall += fmt.Sprintf(
+				"\t\t\t\tlet %s: %s = deserialize(&method_params[%d])?;\n",
+				varName,
+				varType,
+				i,
+			)
+			methodParamNames = append(methodParamNames, varName)
+			methodParamTypes = append(methodParamTypes, varType)
+		}
+
+		if method.Return.Type != "" && method.Return.Type != "void" {
+			methodCall += fmt.Sprintf(
+				"\t\t\t\tlet ans = serialize(obj.%s(%s))?;\n\t\t\t\toutput.push(ans);\n",
+				toRustVarName(method.Name),
+				formatCallArgs(methodParamTypes, methodParamNames),
+			)
+		} else {
+			methodCall += fmt.Sprintf(
+				"\t\t\t\tobj.%s(%s);\n",
+				toRustVarName(method.Name),
+				formatCallArgs(methodParamTypes, methodParamNames),
+			)
+			methodCall += "\t\t\t\toutput.push(\"null\");\n"
+		}
+		methodCall += "\t\t\t}\n"
+		callCode += methodCall
+	}
+
+	callCode = callCode[:len(callCode)-1] // remove last newline
+	testContent := fmt.Sprintf(
+		template,
+		prepareCode,
+		callCode,
+		testCaseOutputMark,
+	)
+	return testContent, nil
+}
+
+func (r rust) generateTestContent(q *leetcode.QuestionData) (string, error) {
+	if q.MetaData.SystemDesign {
+		return r.generateSystemDesignTestCode(q)
+	}
+	return r.generateNormalTestCode(q)
+}
+
+func (r rust) generateCodeFile(
+	q *leetcode.QuestionData,
+	filename string,
+	blocks []config.Block,
+	modifiers []ModifierFunc,
+	separateDescriptionFile bool,
+) (
+	FileOutput,
+	error,
+) {
+	var emptySolution string
+	if !q.MetaData.SystemDesign {
+		emptySolution = "\nstruct Solution;\n"
+	}
+	codeHeader := fmt.Sprintf(
+		`use anyhow::Result;
+use %s::*;
+%s
+`, constants.RustTestUtilsCrate,
+		emptySolution,
+	)
+	testContent, err := r.generateTestContent(q)
+	if err != nil {
+		return FileOutput{}, err
+	}
+	blocks = append(
+		[]config.Block{
+			{
+				Name:     beforeBeforeMarker,
+				Template: codeHeader,
+			},
+			{
+				Name:     afterAfterMarker,
+				Template: testContent,
+			},
+		},
+		blocks...,
+	)
+	content, err := r.generateCodeContent(
+		q,
+		blocks,
+		modifiers,
+		separateDescriptionFile,
+	)
+	if err != nil {
+		return FileOutput{}, err
+	}
+	return FileOutput{
+		Filename: filename,
+		Content:  content,
+		Type:     CodeFile | TestFile,
+	}, nil
+}
+
+func (r rust) GeneratePaths(q *leetcode.QuestionData) (*GenerateResult, error) {
+	filenameTmpl := getFilenameTemplate(q, r)
+	baseFilename, err := q.GetFormattedFilename(r.slug, filenameTmpl)
+	if err != nil {
+		return nil, err
+	}
+	genResult := &GenerateResult{
+		SubDir:   filepath.Join("src", baseFilename),
+		Question: q,
+		Lang:     r,
+	}
+	genResult.AddFile(
+		FileOutput{
+			Filename: "solution.rs",
+			Type:     CodeFile | TestFile,
+		},
+	)
+	genResult.AddFile(
+		FileOutput{
+			Filename: "testcases.txt",
+			Type:     TestCasesFile,
+		},
+	)
+	if separateDescriptionFile(r) {
+		genResult.AddFile(
+			FileOutput{
+				Filename: "question.md",
+				Type:     DocFile,
+			},
+		)
+	}
+	return genResult, nil
+}
+
+type CargoToml struct {
+	CargoFeatures     []string         `toml:"cargo-features,omitempty"`
+	Package           map[string]any   `toml:"package,omitempty"`
+	Lib               map[string]any   `toml:"lib,omitempty"`
+	Bin               []map[string]any `toml:"bin,omitempty"`
+	Example           []map[string]any `toml:"example,omitempty"`
+	Test              []map[string]any `toml:"test,omitempty"`
+	Bench             []map[string]any `toml:"bench,omitempty"`
+	Dependencies      map[string]any   `toml:"dependencies"`
+	DevDependencies   map[string]any   `toml:"dev-dependencies,omitempty"`
+	BuildDependencies map[string]any   `toml:"build-dependencies,omitempty"`
+	Target            map[string]any   `toml:"target,omitempty"`
+	Badge             map[string]any   `toml:"badge,omitempty"`
+	Features          map[string]any   `toml:"features,omitempty"`
+	Patch             map[string]any   `toml:"patch,omitempty"`
+	Replace           map[string]any   `toml:"replace,omitempty"`
+	Profile           map[string]any   `toml:"profile,omitempty"`
+	Workspace         map[string]any   `toml:"workspace,omitempty"`
+}
+
+func addBinSection(result *GenerateResult) error {
+	q := result.Question
+	cargoTomlPath := filepath.Join(result.OutDir, "Cargo.toml")
+	data, err := os.ReadFile(cargoTomlPath)
+	if err != nil {
+		return err
+	}
+
+	var cargo CargoToml
+	err = toml.Unmarshal(data, &cargo)
+	if err != nil {
+		return err
+	}
+
+	exists := false
+	for i, bin := range cargo.Bin {
+		if bin["name"].(string) == q.TitleSlug {
+			cargo.Bin[i] = map[string]any{
+				"name": q.TitleSlug,
+				"path": filepath.Join(result.SubDir, "solution.rs"),
+			}
+			exists = true
+			break
+		}
+	}
+	if !exists {
+		cargo.Bin = append(
+			cargo.Bin, map[string]any{
+				"name": q.TitleSlug,
+				"path": filepath.Join(result.SubDir, "solution.rs"),
+			},
+		)
+	}
+	sort.Slice(
+		cargo.Bin, func(i, j int) bool {
+			return cargo.Bin[i]["path"].(string) < cargo.Bin[j]["path"].(string)
+		},
+	)
+
+	data, err = toml.Marshal(cargo)
+	if err != nil {
+		return err
+	}
+
+	return os.WriteFile(cargoTomlPath, data, 0o644)
+}
+
+func (r rust) Generate(q *leetcode.QuestionData) (*GenerateResult, error) {
+	filenameTmpl := getFilenameTemplate(q, r)
+	baseFilename, err := q.GetFormattedFilename(r.slug, filenameTmpl)
+	if err != nil {
+		return nil, err
+	}
+	genResult := &GenerateResult{
+		Question: q,
+		Lang:     r,
+		SubDir:   filepath.Join("src", baseFilename),
+	}
+
+	separateDescriptionFile := separateDescriptionFile(r)
+	blocks := getBlocks(r)
+	modifiers, err := getModifiers(r, goBuiltinModifiers)
+	if err != nil {
+		return nil, err
+	}
+	codeFile, err := r.generateCodeFile(q, "solution.rs", blocks, modifiers, separateDescriptionFile)
+	if err != nil {
+		return nil, err
+	}
+	testcaseFile, err := r.generateTestCasesFile(q, "testcases.txt")
+	if err != nil {
+		return nil, err
+	}
+	genResult.AddFile(codeFile)
+	genResult.AddFile(testcaseFile)
+
+	if separateDescriptionFile {
+		docFile, err := r.generateDescriptionFile(q, "question.md")
+		if err != nil {
+			return nil, err
+		}
+		genResult.AddFile(docFile)
+	}
+
+	// Add new [[bin]] section to Cargo.toml
+	genResult.ResultHooks = append(genResult.ResultHooks, addBinSection)
+
+	return genResult, nil
+}


Test patch:
diff --git a/lang/rust_test.go b/lang/rust_test.go
new file mode 100644
index 00000000..55e30fb2
--- /dev/null
+++ b/lang/rust_test.go
@@ -0,0 +1,18 @@
+package lang
+
+import "testing"
+
+func TestToRustVarName(t *testing.T) {
+	tests := []struct {
+		name string
+		want string
+	}{
+		{"query", "query"},
+		{"isValidBST", "is_valid_bst"},
+	}
+	for _, tt := range tests {
+		if got := toRustVarName(tt.name); got != tt.want {
+			t.Errorf("toRustVarName(%v) = %v, want %v", tt.name, got, tt.want)
+		}
+	}
+}
diff --git a/lang/test.go b/lang/test.go
index 08de46f9..222ea859 100644
--- a/lang/test.go
+++ b/lang/test.go
@@ -75,7 +75,7 @@ func typeNameToType(ty string) reflect.Type {
 
 func deserialize(tpName string, raw string) (reflect.Value, error) {
 	raw = strings.TrimSpace(raw)
-	goTpName := convertToGoType(tpName)
+	goTpName := toGoType(tpName)
 	ty := typeNameToType(goTpName)
 	if ty == nil {
 		return reflect.Value{}, fmt.Errorf("unknown type: %s", tpName)
diff --git a/testutils/rust/.gitignore b/testutils/rust/.gitignore
new file mode 100644
index 00000000..91230db9
--- /dev/null
+++ b/testutils/rust/.gitignore
@@ -0,0 +1,1 @@
+target/
diff --git a/testutils/rust/Cargo.lock b/testutils/rust/Cargo.lock
new file mode 100644
index 00000000..542b74a5
--- /dev/null
+++ b/testutils/rust/Cargo.lock
@@ -0,0 +1,47 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "anyhow"
+version = "1.0.70"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7de8ce5e0f9f8d88245311066a578d72b7af3e7088f32783804676302df237e4"
+
+[[package]]
+name = "itoa"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6"
+
+[[package]]
+name = "leetgo-rs"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "ryu"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041"
+
+[[package]]
+name = "serde"
+version = "1.0.156"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "314b5b092c0ade17c00142951e50ced110ec27cea304b1037c6969246c2469a4"
+
+[[package]]
+name = "serde_json"
+version = "1.0.94"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c533a59c9d8a93a09c6ab31f0fd5e5f4dd1b8fc9434804029839884765d04ea"
+dependencies = [
+ "itoa",
+ "ryu",
+ "serde",
+]
diff --git a/testutils/rust/Cargo.toml b/testutils/rust/Cargo.toml
new file mode 100644
index 00000000..8c1131d0
--- /dev/null
+++ b/testutils/rust/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "leetgo-rs"
+version = "0.2.0"
+edition = "2021"
+license = "MIT"
+description = "Rust test utils for leetgo"
+homepage = "https://github.com/j178/leetgo"
+
+[dependencies]
+anyhow = "1.0.70"
+serde = "1.0.156"
+serde_json = "1.0.94"
diff --git a/testutils/rust/src/lib.rs b/testutils/rust/src/lib.rs
new file mode 100644
index 00000000..b6f3aef7
--- /dev/null
+++ b/testutils/rust/src/lib.rs
@@ -0,0 +1,9 @@
+pub use list::{LinkedList, ListNode};
+pub use parse::{deserialize, serialize, split_array};
+pub use tree::{BinaryTree, TreeNode};
+pub use utils::*;
+
+mod list;
+mod tree;
+mod parse;
+mod utils;
diff --git a/testutils/rust/src/list.rs b/testutils/rust/src/list.rs
new file mode 100644
index 00000000..87ca243f
--- /dev/null
+++ b/testutils/rust/src/list.rs
@@ -0,0 +1,114 @@
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use serde::de::Visitor;
+use serde::ser::SerializeSeq;
+
+type ListLink = Option<Box<ListNode>>;
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct ListNode {
+    pub val: i32,
+    pub next: ListLink,
+}
+
+#[macro_export]
+macro_rules! list {
+    () => {
+        None
+    };
+    ($e:expr) => {
+        Some(Box::new(ListNode {
+            val: $e,
+            next: None,
+        }))
+    };
+    ($e:expr, $($tail:tt)*) => {
+        Some(Box::new(ListNode {
+            val: $e,
+            next: list!($($tail)*),
+        }))
+    };
+}
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct LinkedList(ListLink);
+
+impl From<LinkedList> for Option<Box<ListNode>> {
+    fn from(list: LinkedList) -> Self {
+        list.0
+    }
+}
+
+impl Serialize for LinkedList {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+        where
+            S: Serializer,
+    {
+        let mut seq = serializer.serialize_seq(None)?;
+        let mut current = &self.0;
+        while let Some(ref node) = current {
+            seq.serialize_element(&node.val)?;
+            current = &node.next;
+        }
+        seq.end()
+    }
+}
+
+struct LinkedListVisitor;
+
+impl<'de> Visitor<'de> for LinkedListVisitor {
+    type Value = LinkedList;
+
+    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
+        formatter.write_str("a list of integers")
+    }
+
+    fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
+        where
+            A: serde::de::SeqAccess<'de>,
+    {
+        let mut head = None;
+        let mut current = &mut head;
+        while let Some(val) = seq.next_element()? {
+            let node = ListNode { val, next: None };
+            *current = Some(Box::new(node));
+            current = &mut current.as_mut().unwrap().next;
+        }
+        Ok(LinkedList(head))
+    }
+}
+
+impl<'de> Deserialize<'de> for LinkedList {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+        where
+            D: Deserializer<'de>,
+    {
+        deserializer.deserialize_seq(LinkedListVisitor)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_list_serialize() {
+        let list = LinkedList(list!(1, 2, 3));
+        let serialized = serde_json::to_string(&list).unwrap();
+        assert_eq!(serialized, "[1,2,3]");
+    }
+
+    #[test]
+    fn test_list_deserialize() {
+        let serialized = "[1,2,3]";
+        let list: LinkedList = serde_json::from_str(serialized).unwrap();
+        assert_eq!(list, LinkedList(list![1, 2, 3]));
+
+        let serialized = "[]";
+        let list: LinkedList = serde_json::from_str(serialized).unwrap();
+        assert!(list.0.is_none());
+
+        let serialized = "[true]";
+        let list = serde_json::from_str::<LinkedList>(serialized);
+        assert!(list.is_err());
+    }
+}
diff --git a/testutils/rust/src/parse.rs b/testutils/rust/src/parse.rs
new file mode 100644
index 00000000..265cc588
--- /dev/null
+++ b/testutils/rust/src/parse.rs
@@ -0,0 +1,51 @@
+use anyhow::{bail, Result};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+pub fn deserialize<'de, T: Deserialize<'de>>(s: &'de str) -> Result<T> {
+    let res: T = serde_json::from_str(s)?;
+    Ok(res)
+}
+
+pub fn serialize<T: Serialize>(v: T) -> Result<String> {
+    let res = serde_json::to_string(&v)?;
+    Ok(res)
+}
+
+pub fn split_array(raw: &str) -> Result<Vec<String>> {
+    let trimmed = raw.trim();
+
+    if trimmed.len() <= 1 || !trimmed.starts_with('[') || !trimmed.ends_with(']') {
+        bail!("invalid array: {}", trimmed);
+    }
+
+    let splits: Vec<Value> = serde_json::from_str(trimmed)?;
+    let res: Vec<String> = splits.iter().map(|v| v.to_string()).collect();
+    Ok(res)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_split_array() {
+        let test_cases = vec![
+            ("[]", vec![]),
+            ("[1]", vec!["1"]),
+            (r#"["a", "b"]"#, vec![r#""a""#, r#""b""#]),
+            ("[1, 2, 3]", vec!["1", "2", "3"]),
+            (r#"[1, "a", null, true, false]"#, vec!["1", r#""a""#, "null", "true", "false"]),
+            ("[1, [2, 3], 4]", vec!["1", "[2,3]", "4"]),
+            ("   [1, 2]  ", vec!["1", "2"]),
+        ];
+
+        for (input, expected) in test_cases {
+            let result = split_array(input);
+            match result {
+                Ok(res) => assert_eq!(res, expected),
+                Err(_) => panic!("Test failed for input: {}", input),
+            }
+        }
+    }
+}
diff --git a/testutils/rust/src/tree.rs b/testutils/rust/src/tree.rs
new file mode 100644
index 00000000..e34c0058
--- /dev/null
+++ b/testutils/rust/src/tree.rs
@@ -0,0 +1,152 @@
+use std::cell::RefCell;
+use std::collections::VecDeque;
+use std::rc::Rc;
+
+use serde::{Deserialize, Serialize, Serializer};
+use serde::de::SeqAccess;
+use serde::ser::SerializeSeq;
+
+// LeetCode use `Option<Rc<RefCell<TreeNode>>>` for tree links, but `Option<Box<TreeNode>>` should be enough.
+// https://github.com/pretzelhammer/rust-blog/blob/master/posts/learning-rust-in-2020.md#leetcode
+type TreeLink = Option<Rc<RefCell<TreeNode>>>;
+
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub struct TreeNode {
+    pub val: i32,
+    pub left: TreeLink,
+    pub right: TreeLink,
+}
+
+#[macro_export]
+macro_rules! tree {
+    () => {
+        None
+    };
+    ($e:expr) => {
+        Some(Rc::new(RefCell::new(TreeNode {
+            val: $e,
+            left: None,
+            right: None,
+        })))
+    };
+}
+
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub struct BinaryTree(TreeLink);
+
+impl From<BinaryTree> for TreeLink {
+    fn from(tree: BinaryTree) -> Self {
+        tree.0
+    }
+}
+
+impl Serialize for BinaryTree {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+        where
+            S: Serializer,
+    {
+        let mut queue = VecDeque::new();
+        let mut nodes = Vec::new();
+        queue.push_back(self.0.clone());
+        while let Some(node) = queue.pop_front() {
+            nodes.push(node.clone());
+            if let Some(ref node) = node {
+                queue.push_back(node.borrow().left.clone());
+                queue.push_back(node.borrow().right.clone());
+            }
+        }
+        while nodes.len() > 0 && nodes.last().unwrap().is_none() {
+            nodes.pop();
+        }
+
+        let mut seq = serializer.serialize_seq(None)?;
+        for node in nodes {
+            if let Some(ref node) = node {
+                seq.serialize_element(&node.borrow().val.clone())?;
+            } else {
+                seq.serialize_element(&None::<i32>)?;
+            }
+        }
+        seq.end()
+    }
+}
+
+struct BinaryTreeVisitor;
+
+impl<'de> serde::de::Visitor<'de> for BinaryTreeVisitor {
+    type Value = BinaryTree;
+
+    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
+        formatter.write_str("a list of optional integers")
+    }
+
+    fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
+        where
+            A: SeqAccess<'de>,
+    {
+        let mut nodes: Vec<TreeLink> = Vec::new();
+
+        while let Some(val) = seq.next_element::<Option<i32>>()? {
+            nodes.push(val.map(|v: i32| Rc::new(RefCell::new(TreeNode {
+                val: v,
+                left: None,
+                right: None,
+            }))));
+        }
+
+        let root = nodes[0].clone();
+        let (mut i, mut j) = (0, 1);
+
+        while j < nodes.len() {
+            if let Some(ref current_node) = nodes[i] {
+                current_node.borrow_mut().left = nodes[j].clone();
+                j += 1;
+                if j < nodes.len() {
+                    current_node.borrow_mut().right = nodes[j].clone();
+                    j += 1;
+                }
+            }
+            i += 1;
+        }
+
+        Ok(BinaryTree(root))
+    }
+}
+
+impl<'de> Deserialize<'de> for BinaryTree {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+        where
+            D: serde::Deserializer<'de>,
+    {
+        deserializer.deserialize_seq(BinaryTreeVisitor)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_tree_serialize() {
+        let root = TreeNode {
+            val: 1,
+            left: Some(Rc::new(RefCell::new(TreeNode {
+                val: 2,
+                left: None,
+                right: None,
+            }))),
+            right: Some(Rc::new(RefCell::new(TreeNode {
+                val: 4,
+                left: Some(Rc::new(RefCell::new(TreeNode {
+                    val: 3,
+                    left: None,
+                    right: None,
+                }))),
+                right: None,
+            }))),
+        };
+        let tree = BinaryTree(Some(Rc::new(RefCell::new(root))));
+        let serialized = serde_json::to_string(&tree).unwrap();
+        assert_eq!(serialized, "[1,2,4,null,null,3]");
+    }
+}
diff --git a/testutils/rust/src/utils.rs b/testutils/rust/src/utils.rs
new file mode 100644
index 00000000..d9a70d87
--- /dev/null
+++ b/testutils/rust/src/utils.rs
@@ -0,0 +1,11 @@
+use anyhow::Result;
+
+pub fn read_line() -> Result<String> {
+    let mut line = String::new();
+    std::io::stdin().read_line(&mut line)?;
+    Ok(line)
+}
+
+pub fn join_array(arr: Vec<String>) -> String {
+    "[".to_string() + &arr.join(",") + "]"
+}