リモートワークで「いま向こう何時?」を答えるとき、Slack の World Clock や macOS の世界時計を開く人は多いと思います。エンジニアならターミナルで:
$ whentime tokyo london ny utcで返ってきてほしい。Rust + clap + chrono-tz で 150 行、依存ゼロのバイナリで実装した話。途中で発見した「UTC+N と思っていたが実は IANA データベースが歴史的なオフセットを持っていた」系の罠も合わせて書きます。
🦀 GitHub: https://github.com/sen-ltd/whentime
📦 Demo (clone-and-run): 後述の Build 手順を参照
「ターミナルで時差」の何が必要か
要件を 4 つに絞った:
-
Friendly alias —
tokyoで済ませたい。Asia/Tokyoを毎回タイプしたくない -
IANA 直指定も可 — alias 表に無い都市は IANA 名で逃げられる (
Europe/Sofia等) -
DST 対応 — London は夏が
+01:00、冬が+00:00。これを「平均すると +0.5」で誤魔化さない -
From me 列 — 自分のタイムゾーンとの時差を
+9h/-13h形式で
From me が要件に入ると「自分の zone」を知らないといけない。Rust の chrono::Local は TZ 環境変数 / システム設定を読んでくれるので、ここはほぼタダ。
chrono と chrono-tz の使い分け
chrono は時刻計算の本体で、IANA tzdata は持ちません。chrono-tz がデータベース部分を提供します。両方入れると Asia::Tokyo のような static な Tz 値が使えるようになります:
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
ここで魔法なのが with_timezone: 内部で Asia::Tokyo の zoneinfo を見て、その UTC instant でのその zone の offset を返します。「UTC+9」を静的に持っているわけではなく、サマータイム制度がある zone なら summer/winter で別の offset を返してくる。
let summer: DateTime<Utc> = "2026-07-15T06:00:00Z".parse().unwrap();
let winter: DateTime<Utc> = "2026-01-15T06:00:00Z".parse().unwrap();
let london = chrono_tz::Europe::London;
println!("{}", summer.with_timezone(&london).offset()); // +01:00
println!("{}", winter.with_timezone(&london).offset()); // +00:00
これが 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");
}
Alias 表は flat array で十分
タイムゾーンの世界、IANA データベースには 600+ の zone がある。Pacific/Funafuti まで含めて。でも「お客さんに『whentime ナンタラ』と聞かれて答えたい都市」は 多くて 60 ぐらい。
なので fuzzy matcher を入れるよりも、flat array で curated な方が:
- typo を fail closed で弾ける(
mars→ exit 2、tokioも exit 2) - 「
bから始まる地名」みたいな ambiguous query を内部で解決しなくて済む - データが visible で、PR で 1 行追加すれば良い
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 行
];
各 row は (エイリアス配列, IANA 名) のペア。lookup は線形 scan で十分速い (60 entries 程度なら HashMap より cache-friendly)。
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()?));
}
}
}
// フォールバック: 直接 IANA としてパース
if let Ok(tz) = input.trim().parse::<Tz>() {
// ... static-leak で寿命を延ばす
}
None
}
- と _ の正規化を入れたのは、new_york / new-york どちらも通したかったから。bash でクオートしないで whentime new york と打つと 2 引数になるので、ハイフンで繋ぐパターンが現実的に多い。
半端な offset を持つ zone — 「UTC+9」では足りない
世界には 30 分単位 / 15 分単位 の zone があって、これを甘く見てると本番で誰かに刺さります:
| 都市 | UTC 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) |
「offset を i32 の hours で持つ」設計だとここで死ぬ。chrono の FixedOffset は 秒単位 なので大丈夫。
From me 列は対象 zone の offset と base zone の offset の差を分単位で計算してフォーマット:
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) }
}
つまり「東京 → 札幌」では 0h、「東京 → デリー」では -3h30m、「Adelaide → London」では -9h30m が返ってくる。タイポしないギリギリの表記です。
エラーは「全部まとめて」報告
whentime tokio londn (両方 typo) みたいなときに「最初の typo で exit する」のは UX が悪い。打ち直しのラウンドトリップが各 typo 毎に発生する。全部 collect してから 1 回のメッセージで返す:
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 (tokyo, ny, london), \
a 3-letter zone (jst, est, gmt), \
or an IANA name (Asia/Tokyo).");
return ExitCode::from(2);
}
exit 2 を選んだのは shell convention で「usage error」を表すから (1 は generic failure)。
表のフォーマット — 列幅は dynamic に
固定幅でハードコードすると Argentina/Buenos_Aires みたいな長い IANA 名が入った時に崩れる。各列の最大幅を rows から計算してから format:
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,
...
);
{:>} は右寄せ、{:} はデフォルト左寄せ。数字系 (時刻、offset、from_me) は右寄せにすると視認性が上がります。
色は --color auto|always|never で、auto のときは is_terminal() + NO_COLOR env を見ます。CI でログに ANSI escape が混入するのを避けたい人向け。
単一バイナリの嬉しさ — Docker でビルド & 配布
chrono-tz は IANA データベースをコンパイル時に埋め込みます。つまり single static binary に全 zone データが入る。実行時に /usr/share/zoneinfo を読みに行かない。Alpine ベースの Docker でビルドすると 4.5MB ぐらい:
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"]
scratch でも動くんですが、shell が無いと docker run --rm でデバッグしにくいので alpine。
Build profile の絞り込み
リリースビルドは hexview と同じ「絞り倒し」プロファイル:
[profile.release]
strip = true
lto = true
codegen-units = 1
opt-level = "z"
panic = "abort"
opt-level = "z" (size optimization) + lto = true (link-time opt) + strip = true (debug シンボル除去) で 4.5MB に。デフォルトの release だと 8.7MB だったので、半分。CLI tools として配布するときは効きます。
まとめ
-
chrono-tzは IANA tzdata をコンパイル時に埋め込むので single static binary で全 zone 対応できる - DST / 半端 offset (
+05:30,+05:45) をchrono::FixedOffsetの秒単位で正しく扱う - alias 表は flat array で curated。fuzzy matcher は typo を吸収しすぎる
- typo は 全部 collect してから 1 回のエラーで返す。エラーメッセージに hint も入れる
-
--color auto/always/never+NO_COLORenv で CI でも安心 - size optimised release profile で 4.5MB の binary
コード全文 — src/main.rs (CLI)、src/cities.rs (alias 表)、src/format.rs (純粋フォーマット)、tests/cli.rs (assert_cmd で black-box)。MIT。
「同じ題材を別レイヤで実装する」シリーズ第 1 弾として、Web 版 → #001 cron-tz-viewer も合わせて見ていただけると面白いかも。
