A 150-Line `whentime tokyo london ny` CLI in Rust — and Why You Need IANA tzdata, Not 'UTC + N'

rust dev.to

When you're working across time zones and someone asks "what time is it in Tokyo?", the answer should be one terminal command away:

$ whentime tokyo london ny utc

Here's a 150-line zero-deps Rust CLI that does exactly that, plus the gotchas you discover when you stop pretending UTC offsets are integers.

🦀 Source on GitHub: https://github.com/sen-ltd/whentime
📦 Build & run: see below

Requirements

Four constraints I gave myself:

  1. Friendly aliases — typing Asia/Tokyo every time loses to typing tokyo.
  2. IANA names too — when an alias isn't in the table (Europe/Sofia), still let the user pass the IANA name directly.
  3. Honest DST — London is +01:00 in July and +00:00 in January. No averaging to "+0.5".
  4. A "From me" column — the actual delta from the user's wall clock, not yet another absolute UTC offset.

That last one means the program needs to know the user's own zone. Rust's chrono::Local reads TZ and the system config, so this is essentially free.

chrono and chrono-tz, in roles

chrono does time math; it doesn't ship IANA tzdata. chrono-tz adds the database — every zone exposed as a static Tz value:

use chrono::{Utc, TimeZone};
use chrono_tz::Asia;

let now = Utc::now();
let tokyo = now.with_timezone(&Asia::Tokyo);
println!("{}", tokyo.format("%H:%M %Z"));   // 07:36 JST
Enter fullscreen mode Exit fullscreen mode

The magic is in with_timezone: it consults Asia::Tokyo's zoneinfo for the offset at this exact instant, not a static "UTC+9". Pass a January datetime to Europe::London and you get +00:00; pass a July datetime and you get +01:00. That makes the DST round-trip a one-line test:

#[test]
fn build_row_handles_dst_transition() {
    let summer: DateTime<Utc> = "2026-07-15T06:00:00Z".parse().unwrap();
    let winter: DateTime<Utc> = "2026-01-15T06:00:00Z".parse().unwrap();
    let s = build_row("London", "Europe/London", chrono_tz::Europe::London, summer, 0);
    let w = build_row("London", "Europe/London", chrono_tz::Europe::London, winter, 0);
    assert_eq!(s.utc_offset, "+01:00");
    assert_eq!(w.utc_offset, "+00:00");
}
Enter fullscreen mode Exit fullscreen mode

Aliases — a flat table beats a fuzzy matcher

The IANA database has 600+ zones, including places nobody asks about. The set of cities engineers actually look up is more like 60. So the alias table is a curated static array:

static ALIASES: &[(&[&str], &str)] = &[
    (&["tokyo", "jst", "tyo", "japan"], "Asia/Tokyo"),
    (&["seoul", "kst", "korea"], "Asia/Seoul"),
    (&["nyc", "ny", "new-york", "manhattan", "est", "edt"], "America/New_York"),
    // ~60 rows
];
Enter fullscreen mode Exit fullscreen mode

A fuzzy matcher would have to deal with tokio (typo for tokyo) vs Pacific/Tongatapu vs ambiguous prefixes. Failing closed is better. The user gets a clean error and a hint:

$ whentime mars venus
error: unknown city or IANA timezone: mars
error: unknown city or IANA timezone: venus
hint: try a city name (tokyo, ny, london), a 3-letter zone (jst, est, gmt),
      or an IANA name (Asia/Tokyo).
Enter fullscreen mode Exit fullscreen mode

(Both typos collected into one report — see below.) The lookup is linear over ~60 entries, which is faster than a HashMap due to cache locality.

pub fn lookup(input: &str) -> Option<(&'static str, Tz)> {
    let normalized = input.trim().to_ascii_lowercase().replace('_', "-");
    for (aliases, tz_name) in ALIASES {
        for &alias in *aliases {
            if alias == normalized {
                return Some((tz_name, tz_name.parse::<Tz>().ok()?));
            }
        }
    }
    // Fallback: try parsing as a literal IANA name.
    input.trim().parse::<Tz>().ok().map(|tz| (/* ... */))
}
Enter fullscreen mode Exit fullscreen mode

Normalising _ and - is a small ergonomic win: whentime new_york and whentime new-york both work.

Half-hour and quarter-hour zones — UTC offsets aren't i32 hours

The world has zones that are not whole-hour offsets. If you've only worked Asia/America/Europe big cities you may not have hit them; here's the list to keep in mind:

Place Offset
Mumbai (IST) +05:30
Kathmandu +05:45
Tehran +03:30 (winter)
Adelaide +09:30 / +10:30 (DST)
Newfoundland −03:30 / −02:30 (DST)
Chatham Islands +12:45 / +13:45 (DST)

Storing offsets as i32 hours is a footgun. chrono::FixedOffset uses seconds, so this is fine; but the formatter has to acknowledge fractional hours:

fn format_relative_offset(minutes: i32) -> String {
    if minutes == 0 { return "0h".to_string(); }
    let sign = if minutes < 0 { '-' } else { '+' };
    let abs = minutes.unsigned_abs();
    let h = abs / 60;
    let m = abs % 60;
    if m == 0 { format!("{}{}h", sign, h) }
    else      { format!("{}{}h{}m", sign, h, m) }
}
Enter fullscreen mode Exit fullscreen mode

So Tokyo → Sapporo prints 0h, Tokyo → Delhi prints -3h30m, Adelaide → London prints -9h30m. Honest, not accidentally rounded.

Collecting all errors before exit

If a user types whentime tokio londn (both typos), exiting at the first failure means two round trips. Better: collect everything, report it together, exit once.

let mut errors = Vec::new();
for input in &cli.cities {
    match lookup(input) {
        Some(pair) => resolved.push(pair),
        None => errors.push(format!("unknown city or IANA timezone: {}", input)),
    }
}
if !errors.is_empty() {
    for e in &errors { eprintln!("error: {}", e); }
    eprintln!("hint: try a city name…");
    return ExitCode::from(2);
}
Enter fullscreen mode Exit fullscreen mode

Exit code 2 because that's the shell convention for "usage error" (1 is generic failure).

Dynamic column widths in the table

Hard-coding column widths breaks when America/Argentina/Buenos_Aires arrives. Compute the max from the actual rows:

let w_iana = rows.iter().map(|r| r.iana.len()).max().unwrap_or(8).max(8);
let line = format!(
    "{:w_city$}  {:w_iana$}  {:>w_clock$}  {:w_date$}  {:>w_offset$}  {:>w_from$}",
    r.city, r.iana, r.local_clock, r.local_date, r.utc_offset, r.from_base,
);
Enter fullscreen mode Exit fullscreen mode

The {:>} modifier right-aligns numeric columns (clock, offset, from-me); city/IANA stay left-aligned. Reads as a human-friendly table.

Colour is opt-in via --color auto|always|never. The auto path checks is_terminal() and respects NO_COLOR (the convention for CI logs that don't render ANSI).

Single static binary via Alpine + chrono-tz

chrono-tz compiles the IANA zoneinfo into the binary. There's no runtime dependency on /usr/share/zoneinfo. Build with the size-optimised profile and it's about 4.5 MB:

[profile.release]
strip = true
lto = true
codegen-units = 1
opt-level = "z"
panic = "abort"
Enter fullscreen mode Exit fullscreen mode

opt-level = "z" is "minimise size", lto = true lets the linker discard unused sections, strip = true removes debug symbols. Default release was 8.7 MB; this gets it to 4.5.

The Dockerfile is the boilerplate rust:1.85-alpine builder + alpine:3.20 runtime:

FROMrust:1.85-alpineASbuilder
RUN apk add --no-cache musl-dev
WORKDIR /build
COPY Cargo.toml Cargo.lock* ./
RUN mkdir -p src && echo 'fn main(){}' > src/main.rs && cargo build --release && rm -rf src
COPY src ./src
COPY tests ./tests
RUN touch src/main.rs && cargo build --release && cargo test --release

FROM alpine:3.20
COPY --from=builder /build/target/release/whentime /usr/local/bin/whentime
ENTRYPOINT ["/usr/local/bin/whentime"]
Enter fullscreen mode Exit fullscreen mode

The two-step COPY Cargo.toml ... && cargo build trick caches dependency compilation in a separate Docker layer — very useful when iterating.

Takeaways

  • chrono-tz ships the IANA database compiled into your binary so a single static executable handles every zone correctly, including historical offsets.
  • DST and half-hour / quarter-hour offsets (India, Nepal, Newfoundland, Chatham) demand seconds-precision offsets, not hours.
  • A flat curated alias table beats a fuzzy matcher for this kind of CLI — typos fail closed with a hint instead of finding ambiguous matches.
  • Collect all errors before exiting so the user can fix every typo in one round trip.
  • Size-optimised release profile (opt-level = "z", LTO, strip) gets the binary under 5 MB, small enough to ship as a CI image.

Full source on GitHubsrc/main.rs (CLI), src/cities.rs (alias table), src/format.rs (pure formatting), tests/cli.rs (assert_cmd black-box). MIT.

This is the first entry in a "same problem, different layer" pair — the web version was cron-tz-viewer (entry #001).

Source: dev.to

arrow_back Back to Tutorials