1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rust + chrono-tz で 150 行の 'whentime tokyo london ny' CLI を書く — IANA tzdata と『UTC+N』近似のあいだに横たわる時差問題

1
Posted at

リモートワークで「いま向こう何時?」を答えるとき、Slack の World Clock や macOS の世界時計を開く人は多いと思います。エンジニアならターミナルで:

$ whentime tokyo london ny utc

で返ってきてほしい。Rust + clap + chrono-tz で 150 行、依存ゼロのバイナリで実装した話。途中で発見した「UTC+N と思っていたが実は IANA データベースが歴史的なオフセットを持っていた」系の罠も合わせて書きます。

whentime のターミナル画面。tokyo / london / ny / la / dubai / sydney / utc を引数に渡すと、City / IANA / Time / Date / UTC offset / From me 列の表が出る。下段は --base tokyo で東京を基準にした例。ダークターミナル風。

🦀 GitHub: https://github.com/sen-ltd/whentime
📦 Demo (clone-and-run): 後述の Build 手順を参照

「ターミナルで時差」の何が必要か

要件を 4 つに絞った:

  1. Friendly aliastokyo で済ませたい。Asia/Tokyo を毎回タイプしたくない
  2. IANA 直指定も可 — alias 表に無い都市は IANA 名で逃げられる (Europe/Sofia 等)
  3. DST 対応 — London は夏が +01:00、冬が +00:00。これを「平均すると +0.5」で誤魔化さない
  4. From me 列 — 自分のタイムゾーンとの時差を +9h / -13h 形式で

From me が要件に入ると「自分の zone」を知らないといけない。Rust の chrono::LocalTZ 環境変数 / システム設定を読んでくれるので、ここはほぼタダ。

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-tzIANA 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_COLOR env で 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 も合わせて見ていただけると面白いかも。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?