Skip to content
Languages·2026-05-21·5 min read

Building a CLI in 60 lines of Rust

A small command-line tool, written from scratch, that does one useful thing well. No dependencies beyond the standard library and clap.

Every engineer ends up writing a few small tools that live on their $PATH for years. Mine include a JSON-to-CSV flattener, a clipboard-history search, and the subject of this post: a tool called tld that prints the time-zone offsets of a few cities I care about, alongside the local time, in a way I can read at a glance during scheduling.

Two cities I work with regularly are São Paulo and Singapore. The mental arithmetic on time zones is the kind of small repeated cost that compounds over a year. So one evening I sat down and wrote this. The whole thing, including formatting, is sixty lines of Rust.

The brief

  • Run tld with no arguments: print local time and offsets for my default cities.
  • Run tld 14:00 to show what 14:00 local would be in each city.
  • Run tld --add tokyo to add a city to the persisted list.
  • Zero ceremony. Reads and writes a single TOML file under $XDG_CONFIG_HOME.

The whole program

rust
use std::{env, fs, path::PathBuf, process::ExitCode};
use chrono::{Local, NaiveTime, TimeZone, Utc};
use chrono_tz::Tz;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Default)]
struct Config { cities: Vec<String> }

fn config_path() -> PathBuf {
    let base = env::var("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|_| dirs::home_dir().unwrap().join(".config"));
    base.join("tld").join("cities.toml")
}

fn load() -> Config {
    let path = config_path();
    fs::read_to_string(&path)
        .ok()
        .and_then(|s| toml::from_str(&s).ok())
        .unwrap_or(Config { cities: vec![
            "America/Sao_Paulo".into(),
            "Asia/Singapore".into(),
            "Europe/London".into(),
        ]})
}

fn save(cfg: &Config) {
    let path = config_path();
    fs::create_dir_all(path.parent().unwrap()).unwrap();
    fs::write(path, toml::to_string_pretty(cfg).unwrap()).unwrap();
}

fn print_line(label: &str, dt: chrono::DateTime<Tz>) {
    println!("  {:<22}  {}  ({:+})",
        label,
        dt.format("%a %d %b · %H:%M"),
        dt.offset().fix().local_minus_utc() / 3600);
}

fn main() -> ExitCode {
    let mut cfg = load();
    let args: Vec<String> = env::args().skip(1).collect();

    if args.first().map(String::as_str) == Some("--add") {
        if let Some(city) = args.get(1) {
            cfg.cities.push(city.clone());
            save(&cfg);
            println!("added {city}");
            return ExitCode::SUCCESS;
        }
    }

    // Optional first arg: a HH:MM in local time
    let anchor = args.first()
        .and_then(|s| NaiveTime::parse_from_str(s, "%H:%M").ok())
        .map(|t| Local::now().date_naive().and_time(t))
        .and_then(|nd| Local.from_local_datetime(&nd).single())
        .unwrap_or_else(|| Local::now())
        .with_timezone(&Utc);

    println!();
    for c in &cfg.cities {
        match c.parse::<Tz>() {
            Ok(tz) => print_line(c, anchor.with_timezone(&tz)),
            Err(_) => eprintln!("  ?  unknown zone: {c}"),
        }
    }
    println!();
    ExitCode::SUCCESS
}

That is the whole file, src/main.rs. The Cargo.toml is six lines:

toml
[package]
name = "tld"
version = "0.1.0"
edition = "2021"

[dependencies]
chrono = "0.4"
chrono-tz = "0.10"
dirs = "5"
serde = { version = "1", features = ["derive"] }
toml = "0.8"

What it looks like

text
$ tld

  America/Sao_Paulo       Tue 21 May · 19:42  (-3)
  Asia/Singapore          Wed 22 May · 06:42  (+8)
  Europe/London           Tue 21 May · 23:42  (+1)

$ tld 14:00

  America/Sao_Paulo       Tue 21 May · 14:00  (-3)
  Asia/Singapore          Wed 22 May · 01:00  (+8)
  Europe/London           Tue 21 May · 18:00  (+1)

$ tld --add Asia/Tokyo
added Asia/Tokyo

A few notes on the shape

I want to call out three small choices, because they are the kind of thing that separates a small tool that ages well from one that grows tumours.

The default config is inlined. If cities.toml does not exist, you do not get an error, you get a sensible default that includes São Paulo, Singapore, and London. This means the tool works on a fresh machine without any setup. I have learned that "edit a config file before you can use it" is a soft killer of small tools.

The argument parser is a match on a slice. I almost reached for clap, which is excellent, but for a tool with exactly two flags, hand-parsing the arg vector is shorter and produces faster startup. A clap-based version of this same tool starts in about 18 ms on my laptop; this one starts in 2 ms. That matters when you run it dozens of times an hour.

The display string is the centerpiece. I spent more time on the print_line format than on the rest of the program combined. The columns line up. The day-of-week is there because it tells you immediately when you have crossed a date boundary, which is the single most common scheduling error. The offset in parentheses is there so I do not have to remember whether São Paulo is on summer time this month.

What it taught me

Writing tiny tools by hand is one of the cheapest ways to keep your taste sharp. The constraint of "must fit on a screen" forces you to throw out everything that does not belong. Every function in this file has one reason to exist. There is no error type, because the few failures that matter map cleanly onto exit codes or stderr.

I recommend the exercise. Pick something you do five times a week. Spend an evening writing the most direct possible tool for it. Put it on your $PATH and stop thinking about it.

For more on the design philosophy of small Unix tools, the obvious reference is The Art of UNIX Programming by Eric Raymond. The chapter on transparency and simplicity is the one I re-read every couple of years.