Skip to main content

tzselect_rs/
lib.rs

1//! tzselect-rs — a faithful Rust port of upstream `tzselect.ksh` (tzdb).
2//!
3//! `tzselect.ksh` (Paul Eggert, public domain) is the **interactive** tzdb
4//! timezone selector: it asks the user (via stderr/stdin) to identify a location
5//! — by continent→country→region, by geographic coordinates (`-c`), by a
6//! proleptic POSIX `TZ` string, or by current local time — and prints the chosen
7//! `TZ` value to stdout. It reads `iso3166.tab` (country codes→names) and
8//! `zone1970.tab` (the zone table), and uses several embedded POSIX-awk programs.
9//!
10//! This crate ports that behaviour to native Rust (no shell, no awk; the
11//! POSIX-`TZ` grammar is hand-parsed, so there are **no runtime dependencies**).
12//! Like the upstream it shells out to `date` for the current-time displays.
13//!
14//! ## Claim boundary
15//!
16//! tzselect-rs **does not define timezone policy or choose a timezone for the
17//! user.** It ports the interactive selection behaviour of upstream
18//! `tzselect.ksh` into Rust, using the same tzdb table surfaces, and verifies
19//! representative prompt/output paths against the upstream shell oracle. It is
20//! the user-selection layer of the Rust tzdb toolchain, beside `zic-rs` and the
21//! producer/QA crates; it does **not** enter libc `localtime`/`strftime` runtime
22//! territory.
23//!
24//! ## Parity contract
25//!
26//! The deterministic oracle is `LC_ALL=C.UTF-8 COLUMNS=1 tzselect` (single-column
27//! `select`, raw-UTF-8 names, bytewise sort). The two live-clock lines
28//! (`Selected time is now:` / `Universal Time is now:`) and the interactive
29//! `time`/`now` menus are **time-dependent** and classified as such. Wide-terminal
30//! multi-column `select` layout and non-UTF-8-locale `iconv` transliteration are
31//! shell/terminal/locale rendering details, also classified.
32#![forbid(unsafe_code)]
33
34/// Host services the port needs from the environment — abstracted so the
35/// interactive driver is unit-testable and the `date` shell-out is injectable.
36pub trait Host {
37    /// Read the next line of user input (stdin), without the trailing newline.
38    /// `None` on EOF/error → the program exits like `read … || exit`.
39    fn read_line(&mut self) -> Option<String>;
40    /// Emit to stderr exactly as given (the caller includes newlines).
41    fn err(&mut self, s: &str);
42    /// Emit the final `TZ` to stdout (`say "$tz"`).
43    fn out(&mut self, s: &str);
44    /// Read `$TZDIR/<basename>` (e.g. `iso3166.tab`); `None` if unreadable.
45    fn read_table(&self, basename: &str) -> Option<String>;
46    /// `LANG=C TZ=<tz> date` → the one output line (no trailing newline).
47    fn run_date(&self, tz: &str) -> Option<String>;
48    /// `TZ=<tz> date +<fmt>` → the one output line.
49    fn run_date_fmt(&self, tz: &str, fmt: &str) -> Option<String>;
50    /// Is `$TZDIR/<tz>` a readable file? (`<"$TZ_for_date"` existence check.)
51    fn zone_readable(&self, tz: &str) -> bool;
52    /// Is stdout a tty? (gates the "make this permanent" hint.)
53    fn stdout_is_tty(&self) -> bool;
54}
55
56/// Parsed command-line / environment options.
57#[derive(Clone, Debug)]
58pub struct Options {
59    /// `-c COORD` (ISO 6709), or `None` for the menu path.
60    pub coord: Option<String>,
61    /// `-n LIMIT` (default 10).
62    pub location_limit: usize,
63    /// `-t TYPE` (undocumented; default `zone1970`).
64    pub zonetabtype: String,
65    /// `$TZDIR` — where the `.tab` files and compiled zones live (default `.`).
66    pub tzdir: String,
67    /// `(tzcode) ` — the baked `PKGVERSION`.
68    pub pkgversion: String,
69    /// e.g. `2026b` — the baked `TZVERSION`.
70    pub tzversion: String,
71    /// `$0` as used in diagnostics (default `tzselect`).
72    pub argv0: String,
73}
74
75impl Default for Options {
76    fn default() -> Self {
77        Options {
78            coord: None,
79            location_limit: 10,
80            zonetabtype: "zone1970".to_string(),
81            tzdir: ".".to_string(),
82            pkgversion: "(tzcode) ".to_string(),
83            tzversion: env!("CARGO_PKG_VERSION").to_string(),
84            argv0: "tzselect".to_string(),
85        }
86    }
87}
88
89/// The usage text (`tzselect.ksh:54-76`).
90pub fn usage(o: &Options) -> String {
91    format!(
92        "Usage: tzselect [--version] [--help] [-c COORD] [-n LIMIT]
93Select a timezone interactively.
94
95Options:
96
97  -c COORD
98    Instead of asking for continent and then country and then city,
99    ask for selection from time zones whose largest cities
100    are closest to the location with geographical coordinates COORD.
101    COORD should use ISO 6709 notation, for example, '-c +4852+00220'
102    for Paris (in degrees and minutes, North and East), or
103    '-c -35-058' for Buenos Aires (in degrees, South and West).
104
105  -n LIMIT
106    Display at most LIMIT locations when -c is used (default {}).
107
108  --version
109    Output version information.
110
111  --help
112    Output this help.
113
114Report bugs to tz@iana.org.",
115        o.location_limit
116    )
117}
118
119/// One iteration's selected state, fed to the common confirmation tail.
120struct Selection {
121    tz: String,
122    time: String,
123    country_result: String,
124    region: String,
125    /// `false` only on the proleptic-`TZ` path (no zoneinfo file to check).
126    needs_zone_check: bool,
127}
128
129/// `say >&2 "$0: <msg>"`.
130fn say_err(h: &mut dyn Host, o: &Options, msg: &str) {
131    h.err(&format!("{}: {}\n", o.argv0, msg));
132}
133/// `say >&2 "<msg>"` (adds the trailing newline).
134fn say_err_raw(h: &mut dyn Host, msg: &str) {
135    h.err(msg);
136    h.err("\n");
137}
138
139/// Run the interactive selector. Returns the process exit code; the chosen `TZ`
140/// is delivered via [`Host::out`].
141pub fn run(o: &Options, h: &mut dyn Host) -> i32 {
142    let country_table = match h.read_table(&format!("{}/iso3166.tab", o.tzdir)) {
143        Some(s) => s,
144        None => {
145            say_err(h, o, "time zone files are not set up correctly");
146            return 1;
147        }
148    };
149    let zonetabtype_table = match h.read_table(&format!("{}/{}.tab", o.tzdir, o.zonetabtype)) {
150        Some(s) => s,
151        None => {
152            say_err(h, o, "time zone files are not set up correctly");
153            return 1;
154        }
155    };
156    let mut zonenow_table: Option<String> = None;
157    let mut coord = o.coord.clone();
158
159    loop {
160        h.err("Please identify a location so that time zone rules can be set correctly.\n");
161
162        let continent = match &coord {
163            Some(c) if !c.is_empty() => "coord".to_string(),
164            _ => match ask_continent(o, h, &zonetabtype_table) {
165                Some(c) => c,
166                None => return 1,
167            },
168        };
169
170        // `now` switches the working table to zonenow.tab (tzselect.ksh:443-448).
171        let mut working_table = zonetabtype_table.clone();
172        if o.zonetabtype != "zonenow" && continent == "now" {
173            if zonenow_table.is_none() {
174                zonenow_table = h.read_table(&format!("{}/zonenow.tab", o.tzdir));
175            }
176            if let Some(t) = &zonenow_table {
177                working_table = t.clone();
178            }
179        }
180
181        let sel = match dispatch(o, h, &continent, &mut coord, &country_table, &working_table) {
182            Dispatch::Exit(code) => return code,
183            Dispatch::Sel(s) => s,
184        };
185
186        // Make sure the zoneinfo file exists (tzselect.ksh:730-735).
187        // TZ_for_date = $TZDIR/$tz for zones; the raw proleptic string otherwise.
188        let tz_for_date = if sel.needs_zone_check {
189            let path = format!("{}/{}", o.tzdir, sel.tz);
190            if !h.zone_readable(&path) {
191                say_err(h, o, "time zone files are not set up correctly");
192                return 1;
193            }
194            path
195        } else {
196            sel.tz.clone()
197        };
198
199        finish(h, &sel, &coord, &tz_for_date);
200
201        match doselect(o, h, &["Yes".to_string(), "No".to_string()]) {
202            None => return 1,
203            Some(ok) if ok == "Yes" => {
204                permanent_hint(o, h, &sel.tz);
205                h.out(&sel.tz);
206                h.out("\n");
207                return 0;
208            }
209            Some(_) => {
210                coord = None; // `do coord= done`
211                continue;
212            }
213        }
214    }
215}
216
217/// Per-continent dispatch result.
218enum Dispatch {
219    Sel(Selection),
220    Exit(i32),
221}
222
223fn dispatch(
224    o: &Options,
225    h: &mut dyn Host,
226    continent: &str,
227    coord: &mut Option<String>,
228    country_table: &str,
229    zone_table: &str,
230) -> Dispatch {
231    match continent {
232        "TZ" => dispatch_tz(o, h),
233        "coord" => dispatch_coord(o, h, coord, country_table, zone_table),
234        "now" | "time" => dispatch_time(o, h, country_table, zone_table),
235        _ => dispatch_normal(o, h, continent, country_table, zone_table),
236    }
237}
238
239/// Proleptic POSIX `TZ` string (tzselect.ksh:453-487).
240fn dispatch_tz(o: &Options, h: &mut dyn Host) -> Dispatch {
241    let tz = loop {
242        h.err("Please enter the desired value of the TZ environment variable.\n");
243        h.err("For example, AEST-10 is abbreviated AEST and is 10 hours\n");
244        h.err("ahead (east) of Greenwich, with no daylight saving time.\n");
245        let entered = match h.read_line() {
246            Some(s) => s,
247            None => return Dispatch::Exit(1),
248        };
249        if posix_tz_valid(&entered) {
250            break entered;
251        }
252        say_err_raw(
253            h,
254            &format!("'{entered}' is not a conforming POSIX proleptic TZ string."),
255        );
256    };
257    let _ = o;
258    Dispatch::Sel(Selection {
259        tz,
260        time: String::new(),
261        country_result: String::new(),
262        region: String::new(),
263        needs_zone_check: false,
264    })
265}
266
267/// Coordinate path (tzselect.ksh:490-538).
268fn dispatch_coord(
269    o: &Options,
270    h: &mut dyn Host,
271    coord: &mut Option<String>,
272    country_table: &str,
273    zone_table: &str,
274) -> Dispatch {
275    let c = match coord {
276        Some(c) if !c.is_empty() => c.clone(),
277        _ => {
278            h.err("Please enter coordinates in ISO 6709 notation.\n");
279            h.err("For example, +4042-07403 stands for\n");
280            h.err("40 degrees 42 minutes north, 74 degrees 3 minutes west.\n");
281            // The script uses a plain `read coord` (no `|| exit`): on EOF it
282            // proceeds with an empty coord, and the following region `select`
283            // handles the EOF (emitting the stdout newline + exit).
284            match h.read_line() {
285                Some(s) => {
286                    *coord = Some(s.clone());
287                    s
288                }
289                None => String::new(),
290            }
291        }
292    };
293    let mut rows = output_distances(&c, country_table, zone_table);
294    rows.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
295    rows.truncate(o.location_limit);
296    let distance_table: Vec<String> = rows
297        .iter()
298        .map(|(d, line)| format!("{}\t{}", fmt_g(*d), line))
299        .collect();
300    let regions: Vec<String> = distance_table
301        .iter()
302        .map(|l| l.rsplit('\t').next().unwrap_or("").to_string())
303        .collect();
304    h.err("Please select one of the following timezones,\n");
305    say_err_raw(
306        h,
307        &format!("listed roughly in increasing order of distance from {c}."),
308    );
309    let region = match doselect(o, h, &regions) {
310        Some(s) => s,
311        None => return Dispatch::Exit(1),
312    };
313    let mut tz = String::new();
314    for l in &distance_table {
315        let f: Vec<&str> = l.split('\t').collect();
316        if f.last().copied().unwrap_or("") == region {
317            tz = f.get(3).copied().unwrap_or("").to_string();
318            break;
319        }
320    }
321    Dispatch::Sel(Selection {
322        tz,
323        time: String::new(),
324        country_result: String::new(),
325        region,
326        needs_zone_check: true,
327    })
328}
329
330/// Current-local-time path (tzselect.ksh:541-608) — time-dependent.
331fn dispatch_time(o: &Options, h: &mut dyn Host, country_table: &str, zone_table: &str) -> Dispatch {
332    let time_table = build_time_table(h, zone_table);
333    let new_minute = h.run_date_fmt("UTC0", "%a %b %d %H:%M").unwrap_or_default();
334    say_err_raw(
335        h,
336        &format!("The system says Universal Time is {new_minute}."),
337    );
338    h.err("Assuming that's correct, what is the local time?\n");
339    let sorted = sort_time_table(&time_table);
340    let mut menu: Vec<String> = Vec::new();
341    let mut last = String::new();
342    for l in &sorted {
343        let key = time_key(l);
344        if key != last {
345            menu.push(key.clone());
346            last = key;
347        }
348    }
349    let time = match doselect(o, h, &menu) {
350        Some(s) => s,
351        None => return Dispatch::Exit(1),
352    };
353    // zone_table = rows whose key == time, first tab field stripped.
354    let mut zt = String::new();
355    for l in &time_table {
356        if time_key(l) == time {
357            if let Some(pos) = l.find('\t') {
358                zt.push_str(&l[pos + 1..]);
359                zt.push('\n');
360            }
361        }
362    }
363    let countries = country_menu(o, h, "^", country_table, &zt);
364    let (cr, country) = match pick_country(o, h, &countries) {
365        Some(v) => v,
366        None => return Dispatch::Exit(1),
367    };
368    let regions = regions_for_country(&country, country_table, &zt);
369    let mut region = String::new();
370    if regions.len() > 1 {
371        h.err("Please select one of the following timezones.\n");
372        match doselect(o, h, &regions) {
373            Some(s) => region = s,
374            None => return Dispatch::Exit(1),
375        }
376    }
377    let tz = derive_tz(&country, &region, country_table, &zt);
378    Dispatch::Sel(Selection {
379        tz,
380        time,
381        country_result: cr.unwrap_or_default(),
382        region,
383        needs_zone_check: true,
384    })
385}
386
387/// Normal continent → country → region path (tzselect.ksh:609-727).
388fn dispatch_normal(
389    o: &Options,
390    h: &mut dyn Host,
391    continent: &str,
392    country_table: &str,
393    zone_table: &str,
394) -> Dispatch {
395    let continent_re = format!("^{continent}/");
396    let countries = country_menu(o, h, &continent_re, country_table, zone_table);
397    let (cr, country) = match pick_country(o, h, &countries) {
398        Some(v) => v,
399        None => return Dispatch::Exit(1),
400    };
401    let regions = regions_for_country(&country, country_table, zone_table);
402    let mut region = String::new();
403    if regions.len() > 1 {
404        h.err("Please select one of the following timezones.\n");
405        match doselect(o, h, &regions) {
406            Some(s) => region = s,
407            None => return Dispatch::Exit(1),
408        }
409    }
410    let tz = derive_tz(&country, &region, country_table, zone_table);
411    Dispatch::Sel(Selection {
412        tz,
413        time: String::new(),
414        country_result: cr.unwrap_or_default(),
415        region,
416        needs_zone_check: true,
417    })
418}
419
420/// The extra-info date loop (tzselect.ksh:743-757) + the confirmation summary
421/// (tzselect.ksh:760-778), up to (not including) the Yes/No menu.
422fn finish(h: &mut dyn Host, sel: &Selection, coord: &Option<String>, tz_for_date: &str) {
423    let mut extra_info = String::new();
424    for _ in 0..8 {
425        let tzdate = h.run_date(tz_for_date).unwrap_or_default();
426        let utdate = h.run_date("UTC0").unwrap_or_default();
427        if secs_match(&tzdate, &utdate) {
428            extra_info =
429                format!("\nSelected time is now:\t{tzdate}.\nUniversal Time is now:\t{utdate}.");
430            break;
431        }
432    }
433
434    h.err("\n");
435    h.err("Based on the following information:\n");
436    h.err("\n");
437
438    let coord_s = coord.clone().unwrap_or_default();
439    let nz = |s: &str| !s.is_empty();
440    let summary = match (
441        nz(&sel.time),
442        nz(&sel.country_result),
443        nz(&sel.region),
444        nz(&coord_s),
445    ) {
446        (true, true, true, false) => {
447            format!("\t{}\n\t{}\n\t{}", sel.time, sel.country_result, sel.region)
448        }
449        (true, true, false, false) | (true, false, true, false) => {
450            format!("\t{}\n\t{}{}", sel.time, sel.country_result, sel.region)
451        }
452        (true, false, false, false) => format!("\t{}", sel.time),
453        (false, true, true, false) => format!("\t{}\n\t{}", sel.country_result, sel.region),
454        (false, true, false, false) => format!("\t{}", sel.country_result),
455        (false, false, true, true) => format!("\tcoord {}\n\t{}", coord_s, sel.region),
456        (false, false, false, true) => format!("\tcoord {coord_s}"),
457        _ => format!("\tTZ='{}'", sel.tz),
458    };
459    say_err_raw(h, &summary);
460    h.err("\n");
461    say_err_raw(h, &format!("TZ='{}' will be used.{extra_info}", sel.tz));
462    h.err("Is the above information OK?\n");
463}
464
465/// The permanent-change hint (tzselect.ksh:788-799), only when stdout is a tty.
466fn permanent_hint(o: &Options, h: &mut dyn Host, tz: &str) {
467    if !h.stdout_is_tty() {
468        return;
469    }
470    let line = format!("export TZ='{tz}'");
471    h.err(&format!(
472        "\nYou can make this change permanent for yourself by appending the line\n\t{line}\nto the file '.profile' in your home directory; then log out and log in again.\n\nHere is that TZ value again, this time on standard output so that you\ncan use the {} command in shell scripts:\n",
473        o.argv0
474    ));
475}
476
477include!("logic.rs");
478
479/// Fuzzing-only entry points (behind the `fuzzing` feature).
480#[cfg(feature = "fuzzing")]
481#[doc(hidden)]
482pub mod fuzz {
483    use super::*;
484
485    /// A scripted in-memory host: input lines from a queue, output discarded,
486    /// fixed `date`, all zones "present". Drives `run` without panicking.
487    struct FuzzHost {
488        lines: std::vec::IntoIter<String>,
489        tables: std::collections::HashMap<String, String>,
490    }
491    impl Host for FuzzHost {
492        fn read_line(&mut self) -> Option<String> {
493            self.lines.next()
494        }
495        fn err(&mut self, _s: &str) {}
496        fn out(&mut self, _s: &str) {}
497        fn read_table(&self, path: &str) -> Option<String> {
498            let base = path.rsplit('/').next().unwrap_or(path);
499            self.tables.get(base).cloned()
500        }
501        fn run_date(&self, _tz: &str) -> Option<String> {
502            Some("Mon Jan  1 00:00:00 UTC 2024".to_string())
503        }
504        fn run_date_fmt(&self, _tz: &str, _fmt: &str) -> Option<String> {
505            Some("2024 01 01 00:00 Mon Jan".to_string())
506        }
507        fn zone_readable(&self, _path: &str) -> bool {
508            true
509        }
510        fn stdout_is_tty(&self) -> bool {
511            false
512        }
513    }
514
515    /// Drive `run` over arbitrary input + arbitrary `iso3166`/`zone1970` tables.
516    pub fn __fuzz_run(input: &str, iso3166: &str, zone1970: &str) {
517        let mut tables = std::collections::HashMap::new();
518        tables.insert("iso3166.tab".to_string(), iso3166.to_string());
519        tables.insert("zone1970.tab".to_string(), zone1970.to_string());
520        tables.insert("zonenow.tab".to_string(), zone1970.to_string());
521        let mut h = FuzzHost {
522            lines: input
523                .lines()
524                .map(|s| s.to_string())
525                .collect::<Vec<_>>()
526                .into_iter(),
527            tables,
528        };
529        let _ = run(&Options::default(), &mut h);
530    }
531
532    /// Exercise the hand-written POSIX-`TZ` grammar validator.
533    pub fn __fuzz_posix_tz(s: &str) {
534        let _ = posix_tz_valid(s);
535    }
536}
537
538#[cfg(kani)]
539mod kani_harness {
540    /// `doselect` returns `items[n - 1]` only inside `(1..=items.len())`. The
541    /// range guard implies `n >= 1` (so `n - 1` never underflows) and
542    /// `n - 1 < len` (so the index is in bounds) for any menu size — no panic.
543    /// This is the only index arithmetic in the interactive driver.
544    #[kani::proof]
545    fn menu_index_guard_is_sound() {
546        let len: usize = kani::any();
547        let n: usize = kani::any();
548        kani::assume(len <= 4096);
549        if (1..=len).contains(&n) {
550            assert!(n >= 1);
551            assert!(n - 1 < len);
552        }
553    }
554}