Building Developer Tools: Rust vs. Go for CLI Applications
As developers, we constantly seek ways to streamline our workflows, automate repetitive tasks, and interact with complex systems more efficiently. This often leads us to the command line interface (CLI) – the bedrock of developer productivity. CLI tools are invaluable for everything from managing cloud resources to orchestrating local development environments or simply fetching data quickly.
But when it comes to building these essential tools, which language should you choose? Rust and Go have emerged as two of the most compelling contenders, each offering unique strengths that make them ideal for CLI development. Both provide performance, reliability, and the ability to compile to a single, statically linked binary – a huge advantage for distribution.
In this comprehensive guide, we’ll dive deep into building robust, user-friendly CLI tools using both Rust and Go. We’ll explore their ecosystems, best practices, and walk through practical examples to help you decide which language best suits your next developer tool project, or perhaps how to leverage both.
The Anatomy of a Great CLI Tool
Before we delve into the specifics of Rust and Go, let’s define what makes a CLI tool truly effective and user-friendly. A well-designed CLI is more than just a script; it’s an application that prioritizes developer experience (DX).
Core Components and Principles:
- Argument Parsing & Subcommands: The ability to accept various inputs (flags, arguments) and organize functionality into logical subcommands (e.g.,
git commit,docker build). - Configuration Management: Handling settings from environmental variables, configuration files (YAML, TOML, JSON), or command-line flags, often with a clear precedence order.
- Input & Output (I/O): Reading from stdin, writing to stdout/stderr. Clear, concise, and often colorized output is crucial.
- Error Handling: Robust and user-friendly error messages that guide the user rather than cryptic stack traces. Exit codes should be meaningful.
- Logging: Providing different levels of detail (debug, info, warn, error) for troubleshooting, either to the console or to a file.
- Testing: Ensuring the tool behaves as expected under various conditions, including valid and invalid inputs.
- Performance: Fast startup times and efficient execution, especially for frequently used tools.
- Distribution: Easy installation and updates, often via static binaries, package managers (Homebrew, APT), or container images.
- Documentation: Comprehensive
--helpmessages, man pages, and a clear README. - User Experience (UX): Beyond functionality, this includes progress indicators for long-running tasks, interactive prompts, and clear feedback.
Building a great CLI tool means thoughtfully considering each of these aspects, ensuring that the tool is not just functional, but a joy to use.
Building with Rust: Performance, Safety, and Control
Rust is a systems programming language focused on performance, memory safety, and concurrency. Its zero-cost abstractions and robust type system make it an excellent choice for CLI tools where reliability and speed are paramount. The Rust ecosystem, centered around Cargo, its package manager and build system, is incredibly vibrant and mature for CLI development.
Why Rust for CLIs?
- Performance: Compiled to native code, Rust applications are incredibly fast, often rivaling C/C++.
- Memory Safety: Rust’s ownership system eliminates entire classes of bugs like null pointer dereferences and data races at compile time.
- Concurrency: Fearless concurrency ensures that multi-threaded operations are safe and efficient.
- Robust Type System: Helps catch errors early in development.
- Static Binaries: Easy to distribute a single executable file with minimal dependencies.
- Developer Experience: While the learning curve can be steep, once mastered, Rust offers a highly productive and enjoyable development experience, especially with its excellent tooling.
Key Rust Crates for CLI Development
Rust’s ecosystem is rich with crates that simplify CLI development:
clap: The go-to library for argument parsing. Powerful, declarative, and generates beautiful help messages.anyhow/thiserror: For ergonomic and robust error handling.anyhowfor application-level errors,thiserrorfor library-level errors.serde: A powerful serialization/deserialization framework, essential for config files (TOML, YAML, JSON) or API interactions.config: A flexible configuration library that handles merging settings from multiple sources (files, env vars, CLI args).tracing: A structured logging framework, highly configurable and performant.indicatif: For elegant progress bars and spinners.reqwest: An ergonomic and powerful HTTP client.tokio: The de-facto asynchronous runtime for high-performance network applications.
Project Setup and Basic CLI Example (proj-finder)
Let’s build a simple tool called proj-finder that scans directories for Rust and Go projects. We’ll start with a basic setup and argument parsing using clap.
First, create a new Rust project:
cargo new proj-finder --bin
cd proj-finder
Add dependencies to your Cargo.toml:
[package]
name = "proj-finder"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4.0", features = ["derive"] }
anyhow = "1.0"
walkdir = "2.3" # For directory traversal
regex = "1.7" # For matching project files
Now, let’s define our CLI structure and a basic scan command in src/main.rs:
use clap::{Parser, Subcommand};
use anyhow::{Result, Context};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use regex::Regex;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Scans a directory for Rust and Go projects
Scan {
/// The directory to scan
#[arg(default_value = ".")]
path: PathBuf,
},
/// Gets detailed information about a specific project
Info {
/// Path to the project (e.g., a directory containing Cargo.toml or go.mod)
path: PathBuf,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
match &cli.command {
Commands::Scan { path } => {
println!("Scanning directory: {}", path.display());
scan_projects(path)?;
}
Commands::Info { path } => {
println!("Getting info for project: {}", path.display());
get_project_info(path)?;
}
}
Ok(())
}
fn scan_projects(root_path: &Path) -> Result<()> {
let rust_re = Regex::new(r"Cargo\.toml").unwrap();
let go_re = Regex::new(r"go\.mod").unwrap();
let mut found_projects = 0;
for entry in WalkDir::new(root_path)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
if rust_re.is_match(file_name) {
println!("[Rust Project] Found: {}", path.parent().unwrap_or(path).display());
found_projects += 1;
} else if go_re.is_match(file_name) {
println!("[Go Project] Found: {}", path.parent().unwrap_or(path).display());
found_projects += 1;
}
}
println!("\nScan complete. Found {} projects.", found_projects);
Ok(())
}
fn get_project_info(project_path: &Path) -> Result<()> {
let cargo_toml_path = project_path.join("Cargo.toml");
let go_mod_path = project_path.join("go.mod");
if cargo_toml_path.exists() {
println!("Type: Rust Project");
println!("Path: {}", project_path.display());
let content = std::fs::read_to_string(&cargo_toml_path)
.context(format!("Failed to read Cargo.toml at {}", cargo_toml_path.display()))?;
// Basic parsing for example
if let Some(name_line) = content.lines().find(|l| l.starts_with("name = ")) {
println!("Name: {}", name_line.split_once("=").unwrap_or(("", "")).1.trim().trim_matches('"'));
}
// Add more detailed info extraction here
} else if go_mod_path.exists() {
println!("Type: Go Project");
println!("Path: {}", project_path.display());
let content = std::fs::read_to_string(&go_mod_path)
.context(format!("Failed to read go.mod at {}", go_mod_path.display()))?;
if let Some(module_line) = content.lines().find(|l| l.starts_with("module ")) {
println!("Module: {}", module_line.split_once(" ").unwrap_or(("", "")).1.trim());
}
// Add more detailed info extraction here
} else {
anyhow::bail!("No Rust (Cargo.toml) or Go (go.mod) project found at {}", project_path.display());
}
Ok(())
}
You can run this with cargo run scan or cargo run scan .. To test the info command, you’d point it to a Rust or Go project directory, e.g., cargo run info ../some_rust_project.
Advanced Concepts in Rust CLI Development
Once you have the basics, you’ll want to incorporate more advanced features:
- Configuration Management: Use the
configcrate withserdeto load settings from~/.config/proj-finder.toml, environment variables, and then CLI overrides. This provides a flexible hierarchy for settings. - Error Handling: The
anyhow::Result<T>and.context()pattern makes error propagation and adding contextual information extremely ergonomic. For library code,thiserroris preferred to define custom error types. - Structured Logging: Integrate
tracing. Define different layers for console output and file logging, filtering by level (e.g.,RUST_LOG=info cargo run scan). - Testing: Rust’s built-in testing framework is powerful. Use unit tests for individual functions and integration tests for end-to-end CLI behavior. Create a
tests/cli.rsfile to test argument parsing and command execution. - Packaging and Distribution:
cargo build --releasecreates optimized binaries. For cross-compilation, usecross. Consider packaging for Homebrew or providing shell scripts for installation.
Architecture Description (Rust):
For a more complex Rust CLI, the architecture typically follows a layered approach:
- CLI Layer (
clap): This is the entry point, responsible for parsing command-line arguments and mapping them to internal commands. It defines the public interface of your tool. - Command/Action Layer: Each subcommand (e.g.,
scan,info) would have its own module or function responsible for orchestrating the specific task. These functions would receive structured arguments from the CLI layer. - Core Logic/Service Layer: This layer contains the reusable business logic, independent of the CLI. For
proj-finder, this would include functions likefind_projects_in_pathorget_project_metadata. These functions often operate on data structures rather than raw strings. - Data Access/External Integration Layer: Handles interactions with the file system (e.g.,
std::fs,walkdir), network (e.g.,reqwest), or databases. Errors from this layer are propagated up and enriched with context.
This separation ensures that your core logic can be easily tested and potentially reused in other contexts (e.g., a GUI or web service) without being tightly coupled to the command-line interface.
Building with Go: Simplicity, Concurrency, and Fast Compilation
Go, often referred to as Golang, is a statically typed, compiled language known for its simplicity, fast compilation times, and powerful concurrency features. It’s particularly well-suited for building network services and developer tools due to its excellent standard library and straightforward deployment model.
Why Go for CLIs?
- Fast Compilation: Go compiles incredibly quickly, leading to rapid development cycles.
- Concurrency Model: Goroutines and channels make concurrent programming approachable and efficient, ideal for tools that need to perform multiple tasks simultaneously (e.g., fetching data from several APIs).
- Excellent Standard Library: Go’s standard library is incredibly comprehensive, often negating the need for third-party dependencies for common tasks like HTTP, JSON parsing, and file I/O.
- Static Binaries: Like Rust, Go compiles to a single, self-contained executable, making distribution simple across various operating systems.
- Simplicity and Readability: Go’s syntax is minimal and easy to read, fostering collaboration and maintainability.
- Cross-platform support: Effortlessly compile for different operating systems and architectures.
Key Go Libraries for CLI Development
While Go’s standard library is robust, several external libraries enhance CLI development:
cobra/urfave/cli: The most popular choices for building powerful, modern CLIs with subcommands, flags, and generated help messages.cobrais used by Kubernetes, Hugo, and GitHub CLI.viper: A comprehensive configuration solution for Go applications, handling config files (JSON, TOML, YAML), environment variables, and command-line flags.sirupsen/logrus/uber-go/zap: Structured logging libraries.zapis known for its extreme performance.fatih/color: For adding colored output to your CLI.spf13/pflag: A POSIX/GNU-compatible flag parsing library, often used bycobra.charmbracelet/lipgloss/charmbracelet/bubbletea: For advanced TUI (Text User Interface) applications.
Project Setup and Basic CLI Example (api-tester)
Let’s build a simple tool called api-tester that can make HTTP GET and POST requests and pretty-print JSON responses. We’ll use cobra for argument parsing.
First, create a new Go module:
mkdir api-tester
cd api-tester
go mod init github.com/khadervali/api-tester # Replace with your module path
go get github.com/spf13/cobra
go get github.com/tidwall/gjson # For simple JSON parsing
Now, let’s set up the basic cobra structure. Create main.go:
package main
import (
"github.com/khadervali/api-tester/cmd"
)
func main() {
cmd.Execute()
}
Next, create the cmd directory and cmd/root.go and cmd/get.go, cmd/post.go files.
cmd/root.go:
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "api-tester",
Short: "A simple CLI tool to test APIs",
Long: `api-tester is a lightweight command-line tool for making HTTP requests
and inspecting responses. It supports GET and POST requests with custom headers and bodies.`,
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func init() {
// Here you can define global flags
// rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file (default is $HOME/.api-tester.yaml)")
}
cmd/get.go:
package cmd
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/tidwall/gjson"
)
var (
getHeaders []string
)
func init() {
rootCmd.AddCommand(getCmd)
getCmd.Flags().StringArrayVarP(&getHeaders, "header", "H", []string{}, "Add a request header (e.g., 'Content-Type: application/json')")
}
var getCmd = &cobra.Command{
Use: "get <URL>",
Short: "Make an HTTP GET request",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
url := args[0]
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err)
return
}
for _, header := range getHeaders {
parts := strings.SplitN(header, ":", 2)
if len(parts) == 2 {
req.Header.Set(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
} else {
fmt.Fprintf(os.Stderr, "Warning: Invalid header format '%s'. Expected 'Key: Value'.\n", header)
}
}
resp, err := client.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "Error making request: %v\n", err)
return
}
defer resp.Body.Close()
fmt.Printf("Status: %s\n", resp.Status)
fmt.Printf("Headers:\n")
for key, values := range resp.Header {
fmt.Printf(" %s: %s\n", key, strings.Join(values, ", "))
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading response body: %v\n", err)
return
}
if json.Valid(bodyBytes) {
fmt.Println("Body (JSON):")
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, bodyBytes, "", " ")
if err != nil {
fmt.Println(string(bodyBytes)) // Fallback to raw if pretty-print fails
} else {
fmt.Println(prettyJSON.String())
}
} else {
fmt.Println("Body:")
fmt.Println(string(bodyBytes))
}
},
}
cmd/post.go:
package cmd
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/tidwall/gjson"
)
var (
postHeaders []string
postBody string
)
func init() {
rootCmd.AddCommand(postCmd)
postCmd.Flags().StringArrayVarP(&postHeaders, "header", "H", []string{}, "Add a request header (e.g., 'Content-Type: application/json')")
postCmd.Flags().StringVarP(&postBody, "data", "d", "", "Request body (e.g., '{\"key\":\"value\"}')")
}
var postCmd = &cobra.Command{
Use: "post <URL>",
Short: "Make an HTTP POST request",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
url := args[0]
var reqBody io.Reader
if postBody != "" {
reqBody = bytes.NewBufferString(postBody)
}
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("POST", url, reqBody)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err)
return
}
// Default to JSON content type if body is present and no Content-Type header is explicitly set
if postBody != "" && !hasHeader(postHeaders, "Content-Type") {
req.Header.Set("Content-Type", "application/json")
}
for _, header := range postHeaders {
parts := strings.SplitN(header, ":", 2)
if len(parts) == 2 {
req.Header.Set(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
} else {
fmt.Fprintf(os.Stderr, "Warning: Invalid header format '%s'. Expected 'Key: Value'.\n", header)
}
}
resp, err := client.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "Error making request: %v\n", err)
return
}
defer resp.Body.Close()
fmt.Printf("Status: %s\n", resp.Status)
fmt.Printf("Headers:\n")
for key, values := range resp.Header {
fmt.Printf(" %s: %s\n", key, strings.Join(values, ", "))
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading response body: %v\n", err)
return
}
if json.Valid(bodyBytes) {
fmt.Println("Body (JSON):")
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, bodyBytes, "", " ")
if err != nil {
fmt.Println(string(bodyBytes)) // Fallback to raw if pretty-print fails
} else {
fmt.Println(prettyJSON.String())
}
} else {
fmt.Println("Body:")
fmt.Println(string(bodyBytes))
}
},
}
func hasHeader(headers []string, key string) bool {
for _, header := range headers {
parts := strings.SplitN(header, ":", 2)
if len(parts) == 2 && strings.EqualFold(strings.TrimSpace(parts[0]), key) {
return true
}
}
return false
}
You can build and run this with go build -o api-tester . and then ./api-tester get https://jsonplaceholder.typicode.com/todos/1 or ./api-tester post -d '{"title":"foo","body":"bar","userId":1}' https://jsonplaceholder.typicode.com/posts.
Advanced Concepts in Go CLI Development
To move beyond basic commands:
- Configuration Management:
viperis the standard. Initialize it inroot.go, set up config file paths, environment variable prefixes, and merge with CLI flags. - Context for Cancellation/Timeouts: Use
context.Contextfor long-running operations, allowing users to gracefully cancel tasks (e.g., with Ctrl+C) or for operations to time out. - Concurrency: For tasks requiring multiple concurrent operations (e.g., fetching data from several endpoints), gor
Khader Vali
Senior Software Engineer specializing in cloud architecture, real-time systems, and enterprise-scale applications.