System Design

Build Robust CLI Tools with Rust and Go for Developers

Master building powerful CLI tools with Rust and Go. Enhance developer workflows, automate tasks, and create robust applications with this comprehensive guide.

Khader Vali August 8, 2026 14 min read

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:

  1. Argument Parsing & Subcommands: The ability to accept various inputs (flags, arguments) and organize functionality into logical subcommands (e.g., git commit, docker build).
  2. Configuration Management: Handling settings from environmental variables, configuration files (YAML, TOML, JSON), or command-line flags, often with a clear precedence order.
  3. Input & Output (I/O): Reading from stdin, writing to stdout/stderr. Clear, concise, and often colorized output is crucial.
  4. Error Handling: Robust and user-friendly error messages that guide the user rather than cryptic stack traces. Exit codes should be meaningful.
  5. Logging: Providing different levels of detail (debug, info, warn, error) for troubleshooting, either to the console or to a file.
  6. Testing: Ensuring the tool behaves as expected under various conditions, including valid and invalid inputs.
  7. Performance: Fast startup times and efficient execution, especially for frequently used tools.
  8. Distribution: Easy installation and updates, often via static binaries, package managers (Homebrew, APT), or container images.
  9. Documentation: Comprehensive --help messages, man pages, and a clear README.
  10. 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. anyhow for application-level errors, thiserror for 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:

  1. Configuration Management: Use the config crate with serde to load settings from ~/.config/proj-finder.toml, environment variables, and then CLI overrides. This provides a flexible hierarchy for settings.
  2. Error Handling: The anyhow::Result<T> and .context() pattern makes error propagation and adding contextual information extremely ergonomic. For library code, thiserror is preferred to define custom error types.
  3. Structured Logging: Integrate tracing. Define different layers for console output and file logging, filtering by level (e.g., RUST_LOG=info cargo run scan).
  4. 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.rs file to test argument parsing and command execution.
  5. Packaging and Distribution: cargo build --release creates optimized binaries. For cross-compilation, use cross. 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:

Build Robust CLI Tools with Rust and Go for Developers
Generated Image

  • 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 like find_projects_in_path or get_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. cobra is 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. zap is 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 by cobra.
  • 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:

  1. Configuration Management: viper is the standard. Initialize it in root.go, set up config file paths, environment variable prefixes, and merge with CLI flags.
  2. Context for Cancellation/Timeouts: Use context.Context for long-running operations, allowing users to gracefully cancel tasks (e.g., with Ctrl+C) or for operations to time out.
  3. Concurrency: For tasks requiring multiple concurrent operations (e.g., fetching data from several endpoints), gor
Written by

Khader Vali

Senior Software Engineer specializing in cloud architecture, real-time systems, and enterprise-scale applications.

Share this article

Related Articles

Build Real-Time Streaming Pipelines with Kafka & Flink

Jul 27, 2026 · 16 min read

CQRS & Event Sourcing for Scalable Applications

Jun 16, 2026 · 13 min read

Scalable WebSockets: Building Real-Time Infrastructure

Jul 07, 2026 · 18 min read