// Portable TZif command-line inspector.
//
// The library deliberately has no filesystem dependency so it can also run
// on wasm targets. The executable therefore accepts TZif bytes as hex; a
// native host adapter can read a zoneinfo file and pass its contents here.
///|
fn print_usage() -> Unit {
println("tzif — portable TZif v2/v3 inspector")
println("")
println("USAGE:")
println(" tzif info ")
println(" tzif utc-to-local ")
println(
" tzif local-to-utc [earlier|later]",
)
println(" tzif resolve-local ")
println(" tzif transition ")
println(" tzif transitions ")
println("")
println("The hexadecimal input is the raw content of a TZif file.")
}
///|
fn print_version() -> Unit {
println("tzif-engine 0.1.0")
}
///|
fn print_error(message : String) -> Unit {
println("error: " + message)
}
///|
fn hex_value(code : Int) -> Int? {
if code >= '0'.to_int() && code <= '9'.to_int() {
Some(code - '0'.to_int())
} else if code >= 'a'.to_int() && code <= 'f'.to_int() {
Some(code - 'a'.to_int() + 10)
} else if code >= 'A'.to_int() && code <= 'F'.to_int() {
Some(code - 'A'.to_int() + 10)
} else {
None
}
}
///|
fn decode_hex(input : String) -> Bytes? {
let length = input.length()
if length == 0 || length % 2 != 0 {
return None
}
let output : Array[Byte] = []
for index = 0; index < length; index = index + 2 {
let high = match hex_value(input[index].to_int()) {
Some(value) => value
None => return None
}
let low = match hex_value(input[index + 1].to_int()) {
Some(value) => value
None => return None
}
output.push((high * 16 + low).to_byte())
}
Some(Bytes::from_array(output))
}
///|
fn parse_timestamp(input : String) -> Int64? {
Some(@string.parse_int64(input, base=10)) catch {
_ => None
}
}
///|
fn parse_index(input : String) -> Int? {
Some(@string.parse_int(input, base=10)) catch {
_ => None
}
}
///|
fn parse_strategy(input : String) -> @types.AmbiguityStrategy? {
match input {
"earlier" => Some(PreferEarlier)
"later" => Some(PreferLater)
_ => None
}
}
///|
fn print_local(local_time : @types.LocalDateTime) -> Bool {
match @tzif.format_iso_zoned(local_time) {
Ok(value) => {
println("\{value} \{local_time.abbr}")
true
}
Err(_) => {
print_error("local time is outside the supported ISO-8601 output range")
false
}
}
}
///|
fn format_offset_for_diagnostic(offset : Int) -> String {
match @tzif.format_numeric_offset(offset) {
Ok(value) => value
Err(_) =>
if offset >= 0 {
"+" + offset.to_string() + "s"
} else {
offset.to_string() + "s"
}
}
}
///|
fn print_transition_detail(detail : @types.TransitionDetail) -> Bool {
let change = match detail.clock_change {
ClockForward => "forward"
ClockBackward => "backward"
NoClockChange => "no wall-clock change"
}
let utc_text = match @tzif.format_iso_utc(detail.utc_time) {
Ok(value) => value
Err(_) => {
print_error("transition timestamp is outside the ISO-8601 output range")
return false
}
}
let before_offset = format_offset_for_diagnostic(detail.before.utoff)
let after_offset = format_offset_for_diagnostic(detail.after.utoff)
println("transition #\{detail.index} at \{utc_text}")
println(" before: \{detail.before.abbr} (UTC\{before_offset})")
println(" after: \{detail.after.abbr} (UTC\{after_offset})")
println(" clock: \{change}, offset delta \{detail.offset_change} seconds")
println(" local boundary: \{detail.local_before} -> \{detail.local_after}")
true
}
///|
fn parse_local_argument(input : String) -> @types.LocalDateTime? {
match @tzif.parse_iso_local(input) {
Ok(value) => Some(value)
Err(_) => {
print_error("local time must use YYYY-MM-DDTHH:MM:SS")
None
}
}
}
///|
fn load_hex(input : String) -> @types.TzifData? {
let bytes = match decode_hex(input) {
Some(value) => value
None => {
print_error(
"TZif input must be a non-empty even-length hexadecimal string",
)
return None
}
}
match @tzif.load(bytes) {
Ok(timezone) => Some(timezone)
Err(_) => {
print_error("invalid or unsupported TZif v2/v3 data")
None
}
}
}
///|
fn run_cli(args : Array[String]) -> Int {
if args.length() == 0 {
print_usage()
return 2
}
match args[0] {
"--help" | "-h" | "help" =>
if args.length() == 1 {
print_usage()
0
} else {
print_usage()
2
}
"--version" | "version" =>
if args.length() == 1 {
print_version()
0
} else {
print_usage()
2
}
"info" =>
if args.length() != 2 {
print_usage()
2
} else {
match load_hex(args[1]) {
Some(timezone) => {
println(@tzif.info(timezone))
0
}
None => 2
}
}
"utc-to-local" =>
if args.length() != 3 {
print_usage()
2
} else {
let timezone = match load_hex(args[1]) {
Some(value) => value
None => return 2
}
let timestamp = match parse_timestamp(args[2]) {
Some(value) => value
None => {
print_error("unix-seconds must be a signed Int64 decimal value")
return 2
}
}
match @tzif.utc_to_local(timezone, timestamp) {
Ok(local_time) => if print_local(local_time) { 0 } else { 1 }
Err(_) => {
print_error("timezone conversion failed")
1
}
}
}
"local-to-utc" =>
if args.length() != 3 && args.length() != 4 {
print_usage()
2
} else {
let timezone = match load_hex(args[1]) {
Some(value) => value
None => return 2
}
let local_time = match parse_local_argument(args[2]) {
Some(value) => value
None => return 2
}
let strategy = if args.length() == 4 {
match parse_strategy(args[3]) {
Some(value) => value
None => {
print_error("ambiguity strategy must be earlier or later")
return 2
}
}
} else {
PreferEarlier
}
match @tzif.local_to_utc(timezone, local_time, strategy) {
Ok(utc_time) =>
match @tzif.format_iso_utc(utc_time) {
Ok(value) => {
println("\{value} (unix \{utc_time})")
0
}
Err(_) => {
print_error("UTC result is outside the ISO-8601 output range")
1
}
}
Err(InvalidLocalTime(_)) => {
print_error("local time falls in a DST gap")
1
}
Err(_) => {
print_error("local time could not be resolved")
1
}
}
}
"resolve-local" =>
if args.length() != 3 {
print_usage()
2
} else {
let timezone = match load_hex(args[1]) {
Some(value) => value
None => return 2
}
let local_time = match parse_local_argument(args[2]) {
Some(value) => value
None => return 2
}
match @tzif.resolve_local(timezone, local_time) {
Ok(Unique(utc_time)) =>
match @tzif.format_iso_utc(utc_time) {
Ok(value) => {
println("unique: \{value} (unix \{utc_time})")
0
}
Err(_) => {
print_error("UTC result is outside the ISO-8601 output range")
1
}
}
Ok(Gap) => {
println("gap: local time does not occur in this timezone")
0
}
Ok(Ambiguous(candidates)) => {
println("ambiguous: \{candidates.length()} UTC candidates")
for utc_time in candidates {
let value = match @tzif.format_iso_utc(utc_time) {
Ok(text) => text
Err(_) => {
print_error("UTC result is outside the ISO-8601 output range")
return 1
}
}
println(" \{value} (unix \{utc_time})")
}
0
}
Err(_) => {
print_error("local time could not be resolved")
1
}
}
}
"transition" =>
if args.length() != 3 {
print_usage()
2
} else {
let timezone = match load_hex(args[1]) {
Some(value) => value
None => return 2
}
let index = match parse_index(args[2]) {
Some(value) => value
None => {
print_error("transition index must be a signed decimal Int")
return 2
}
}
match @tzif.transition_detail(timezone, index) {
Ok(detail) => if print_transition_detail(detail) { 0 } else { 1 }
Err(_) => {
print_error("transition index is out of range or data is invalid")
1
}
}
}
"transitions" =>
if args.length() != 4 {
print_usage()
2
} else {
let timezone = match load_hex(args[1]) {
Some(value) => value
None => return 2
}
let start_utc = match parse_timestamp(args[2]) {
Some(value) => value
None => {
print_error("start-unix must be a signed Int64 decimal value")
return 2
}
}
let end_utc = match parse_timestamp(args[3]) {
Some(value) => value
None => {
print_error("end-unix must be a signed Int64 decimal value")
return 2
}
}
match @tzif.transition_details_between(timezone, start_utc, end_utc) {
Ok(details) => {
if details.length() == 0 {
println("no explicit transitions in range")
} else {
for detail in details {
if !print_transition_detail(detail) {
return 1
}
}
}
0
}
Err(_) => {
print_error("invalid transition range or timezone data")
1
}
}
}
_ => {
print_usage()
2
}
}
}
///|
fn main {
let argv = @sys.get_cli_args()
let arguments = if argv.length() > 0 { argv[1:].to_owned() } else { [] }
@sys.exit(run_cli(arguments))
}