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 utcHere'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:
-
Friendly aliases — typing
Asia/Tokyoevery time loses to typingtokyo. -
IANA names too — when an alias isn't in the table (
Europe/Sofia), still let the user pass the IANA name directly. -
Honest DST — London is
+01:00in July and+00:00in January. No averaging to "+0.5". - 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
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");
}
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
];
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).
(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| (/* ... */))
}
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) }
}
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);
}
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,
);
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"
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:
FROM rust:1.85-alpine AS builder
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"]
The two-step COPY Cargo.toml ... && cargo build trick caches dependency compilation in a separate Docker layer — very useful when iterating.
Takeaways
-
chrono-tzships 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 GitHub — src/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).

Top comments (0)