1use chrono::format::{Item, StrftimeItems};
6use chrono::{Datelike, Duration, Months, NaiveDate, NaiveDateTime, TimeZone, Timelike, Utc};
7
8use crate::eval::coercion::to_number;
9use crate::eval::evaluate_expr;
10use crate::eval::functions::date::serial::{
11 date_to_serial, serial_to_date, serial_to_time, time_to_serial,
12};
13use crate::eval::functions::{check_arity, check_arity_len, EvalCtx, FunctionMeta, Registry};
14use crate::parser::ast::Expr;
15use crate::types::zoned::{parse_rfc9557, parse_zone, AmbiguousPolicy};
16use crate::types::{ErrorKind, Value, ZoneId, ZonedInstant};
17
18#[cfg(test)]
19mod tests;
20
21pub fn register_timezone(registry: &mut Registry) {
22 registry.register_eager("TZDBVERSION", tzdbversion_fn, FunctionMeta { category: "timezone", signature: "TZDBVERSION()", description: "IANA time-zone database version the engine is using" });
23 registry.register_eager("TZVALID", tzvalid_fn, FunctionMeta { category: "timezone", signature: "TZVALID(zone)", description: "TRUE if the text is a valid IANA zone name or fixed offset" });
24 registry.register_eager("ISZONED", iszoned_fn, FunctionMeta { category: "timezone", signature: "ISZONED(value)", description: "TRUE if the value is a zone-aware instant" });
25 registry.register_eager("TZDATETIME", tzdatetime_fn, FunctionMeta { category: "timezone", signature: "TZDATETIME(year,month,day,hour,minute,second,zone,[policy])", description: "Builds a zone-aware instant from a wall-clock time in a zone" });
26 registry.register_eager("TZCONVERT", tzconvert_fn, FunctionMeta { category: "timezone", signature: "TZCONVERT(zoned,target_zone)", description: "Same instant shown as the local time in another zone" });
27 registry.register_eager("TZSERIAL", tzserial_fn, FunctionMeta { category: "timezone", signature: "TZSERIAL(zoned)", description: "Drops the zone, returning the wall-clock time as a plain date serial" });
28 registry.register_eager("TZOFFSET", tzoffset_fn, FunctionMeta { category: "timezone", signature: "TZOFFSET(zoned)", description: "Offset from UTC in minutes at this instant (DST-resolved)" });
29 registry.register_eager("TZSTRING", tzstring_fn, FunctionMeta { category: "timezone", signature: "TZSTRING(zoned)", description: "Canonical RFC-9557 string for a zone-aware instant" });
30 registry.register_eager("TZLOCALIZE", tzlocalize_fn, FunctionMeta { category: "timezone", signature: "TZLOCALIZE(date_serial,zone,[policy])", description: "Interprets a plain date/time serial as a wall-clock reading in a zone" });
31 registry.register_eager("TZFROMEPOCH", tzfromepoch_fn, FunctionMeta { category: "timezone", signature: "TZFROMEPOCH(unix_seconds,zone)", description: "Builds a zone-aware instant from a Unix timestamp" });
32 registry.register_eager("TZPARSE", tzparse_fn, FunctionMeta { category: "timezone", signature: "TZPARSE(text,[zone])", description: "Parses an ISO/RFC-9557 timestamp into a zone-aware instant" });
33 registry.register_eager("TZISDST", tzisdst_fn, FunctionMeta { category: "timezone", signature: "TZISDST(zoned)", description: "TRUE if daylight-saving is in effect at this instant" });
34 registry.register_eager("TZABBR", tzabbr_fn, FunctionMeta { category: "timezone", signature: "TZABBR(zoned)", description: "Zone abbreviation at this instant (e.g. CEST), display only" });
35 registry.register_eager("TZOFFSETDIFF", tzoffsetdiff_fn, FunctionMeta { category: "timezone", signature: "TZOFFSETDIFF(zoned_a,zoned_b)", description: "Signed difference in minutes between two zones' offsets" });
36 registry.register_eager("TZPART", tzpart_fn, FunctionMeta { category: "timezone", signature: "TZPART(zoned,unit)", description: "Local wall-clock field: year|month|day|hour|minute|second|weekday" });
37 registry.register_eager("TZDIFF", tzdiff_fn, FunctionMeta { category: "timezone", signature: "TZDIFF(zoned_a,zoned_b,[unit])", description: "Elapsed time from b to a on the absolute timeline (DST-immune)" });
38 registry.register_eager("TZADD", tzadd_fn, FunctionMeta { category: "timezone", signature: "TZADD(zoned,amount,unit,[policy])", description: "Adds time: seconds/minutes/hours are absolute, days/weeks/months/years are DST-aware calendar units" });
39 registry.register_lazy("TZNOW", tznow_fn, FunctionMeta { category: "timezone", signature: "TZNOW(zone)", description: "The current moment stamped with the given zone (volatile; pinned during recalc)" });
40 registry.register_eager("TZINWINDOW", tzinwindow_fn, FunctionMeta { category: "timezone", signature: "TZINWINDOW(zoned,start_local,end_local,[days_mask])", description: "TRUE if the local time-of-day falls in [start,end); days_mask is 7 chars Sun..Sat" });
41 registry.register_eager("TZCANONICAL", tzcanonical_fn, FunctionMeta { category: "timezone", signature: "TZCANONICAL(zone)", description: "Validates and normalizes a zone name (best-effort; IANA links are not resolved)" });
42 registry.register_eager("TZLOCALSTATUS", tzlocalstatus_fn, FunctionMeta { category: "timezone", signature: "TZLOCALSTATUS(year,month,day,hour,minute,zone)", description: "Classifies a local time as unique, gap (nonexistent) or fold (ambiguous)" });
43 registry.register_eager("TZTEXT", tztext_fn, FunctionMeta { category: "timezone", signature: "TZTEXT(zoned,[format])", description: "Human-friendly local string; optional strftime format" });
44 registry.register_eager("TZBOARD", tzboard_fn, FunctionMeta { category: "timezone", signature: "TZBOARD(anchor,zones,[base])", description: "Technical world-clock board: one row per zone {zone,local,offset,delta,rollover,abbrev,dst}" });
45 registry.register_eager("TZTABLE", tzboard_fn, FunctionMeta { category: "timezone", signature: "TZTABLE(anchor,zones,[base])", description: "Alias of TZBOARD" });
46 registry.register_eager("TZWORLDCLOCK", tzworldclock_fn, FunctionMeta { category: "timezone", signature: "TZWORLDCLOCK(anchor,zones,[base])", description: "Friendly one-line summary comparing the time across N zones" });
47 registry.register_eager("TZCOMPARETEXT", tzworldclock_fn, FunctionMeta { category: "timezone", signature: "TZCOMPARETEXT(anchor,zones,[base])", description: "Alias of TZWORLDCLOCK" });
48 registry.register_eager("TZOVERLAP", tzoverlap_fn, FunctionMeta { category: "timezone", signature: "TZOVERLAP(zones,start_local,end_local,date_serial,[granularity_min])", description: "First daily window when the local time is within [start,end) in ALL zones; [start,end] instants or 'No overlap'" });
49}
50
51fn arg_zoned(v: &Value) -> Result<&ZonedInstant, Value> {
55 match v {
56 Value::Zoned(z) => Ok(z),
57 _ => Err(Value::Error(ErrorKind::Value)),
58 }
59}
60
61fn arg_zone(v: &Value) -> Result<ZoneId, Value> {
63 match v {
64 Value::Text(s) => parse_zone(s).ok_or(Value::Error(ErrorKind::Value)),
65 _ => Err(Value::Error(ErrorKind::Value)),
66 }
67}
68
69fn int_arg(v: &Value) -> Result<i64, Value> {
71 Ok(to_number(v.clone())?.trunc() as i64)
72}
73
74fn zoned(zi: ZonedInstant) -> Value {
75 Value::Zoned(Box::new(zi))
76}
77
78pub fn tzdbversion_fn(args: &[Value]) -> Value {
81 if let Some(e) = check_arity(args, 0, 0) {
82 return e;
83 }
84 Value::Text(chrono_tz::IANA_TZDB_VERSION.to_string())
85}
86
87pub fn tzvalid_fn(args: &[Value]) -> Value {
88 if let Some(e) = check_arity(args, 1, 1) {
89 return e;
90 }
91 match &args[0] {
92 Value::Text(s) => Value::Bool(parse_zone(s).is_some()),
93 _ => Value::Bool(false),
94 }
95}
96
97pub fn iszoned_fn(args: &[Value]) -> Value {
98 if let Some(e) = check_arity(args, 1, 1) {
99 return e;
100 }
101 Value::Bool(matches!(args[0], Value::Zoned(_)))
102}
103
104pub fn tzdatetime_fn(args: &[Value]) -> Value {
105 if let Some(e) = check_arity(args, 7, 8) {
106 return e;
107 }
108 let parts = match (0..6).map(|i| int_arg(&args[i])).collect::<Result<Vec<_>, _>>() {
109 Ok(p) => p,
110 Err(e) => return e,
111 };
112 let zone = match arg_zone(&args[6]) {
113 Ok(z) => z,
114 Err(e) => return e,
115 };
116 let policy = match optional_policy(args.get(7)) {
117 Ok(p) => p,
118 Err(e) => return e,
119 };
120
121 let (year, month, day, hour, minute, second) =
122 (parts[0], parts[1], parts[2], parts[3], parts[4], parts[5]);
123 let naive = match (
124 i32::try_from(year).ok(),
125 u32::try_from(month).ok(),
126 u32::try_from(day).ok(),
127 u32::try_from(hour).ok(),
128 u32::try_from(minute).ok(),
129 u32::try_from(second).ok(),
130 ) {
131 (Some(y), Some(mo), Some(d), Some(h), Some(mi), Some(s)) => NaiveDate::from_ymd_opt(y, mo, d)
132 .and_then(|date| date.and_hms_opt(h, mi, s)),
133 _ => None,
134 };
135 let naive = match naive {
136 Some(n) => n,
137 None => return Value::Error(ErrorKind::Value),
138 };
139 match ZonedInstant::from_local(zone, naive, policy) {
140 Some(zi) => zoned(zi),
141 None => Value::Error(ErrorKind::Value),
142 }
143}
144
145pub fn tzconvert_fn(args: &[Value]) -> Value {
146 if let Some(e) = check_arity(args, 2, 2) {
147 return e;
148 }
149 let zi = match arg_zoned(&args[0]) {
150 Ok(z) => z,
151 Err(e) => return e,
152 };
153 let target = match arg_zone(&args[1]) {
154 Ok(z) => z,
155 Err(e) => return e,
156 };
157 zoned(ZonedInstant::from_instant(zi.utc_nanos, target))
158}
159
160pub fn tzserial_fn(args: &[Value]) -> Value {
161 if let Some(e) = check_arity(args, 1, 1) {
162 return e;
163 }
164 let zi = match arg_zoned(&args[0]) {
165 Ok(z) => z,
166 Err(e) => return e,
167 };
168 let local = zi.local();
169 let serial = date_to_serial(local.date())
170 + time_to_serial(local.hour(), local.minute(), local.second());
171 Value::Date(serial)
172}
173
174pub fn tzoffset_fn(args: &[Value]) -> Value {
175 if let Some(e) = check_arity(args, 1, 1) {
176 return e;
177 }
178 match arg_zoned(&args[0]) {
179 Ok(zi) => Value::Number(zi.offset_minutes() as f64),
180 Err(e) => e,
181 }
182}
183
184pub fn tzstring_fn(args: &[Value]) -> Value {
185 if let Some(e) = check_arity(args, 1, 1) {
186 return e;
187 }
188 match arg_zoned(&args[0]) {
189 Ok(zi) => Value::Text(zi.to_rfc9557()),
190 Err(e) => e,
191 }
192}
193
194pub fn tzlocalize_fn(args: &[Value]) -> Value {
195 if let Some(e) = check_arity(args, 2, 3) {
196 return e;
197 }
198 let serial = match to_number(args[0].clone()) {
199 Ok(n) => n,
200 Err(e) => return e,
201 };
202 let zone = match arg_zone(&args[1]) {
203 Ok(z) => z,
204 Err(e) => return e,
205 };
206 let policy = match optional_policy(args.get(2)) {
207 Ok(p) => p,
208 Err(e) => return e,
209 };
210 let naive = match serial_to_naive(serial) {
211 Some(n) => n,
212 None => return Value::Error(ErrorKind::Value),
213 };
214 match ZonedInstant::from_local(zone, naive, policy) {
215 Some(zi) => zoned(zi),
216 None => Value::Error(ErrorKind::Value),
217 }
218}
219
220pub fn tzfromepoch_fn(args: &[Value]) -> Value {
221 if let Some(e) = check_arity(args, 2, 2) {
222 return e;
223 }
224 let secs = match to_number(args[0].clone()) {
225 Ok(n) => n,
226 Err(e) => return e,
227 };
228 let zone = match arg_zone(&args[1]) {
229 Ok(z) => z,
230 Err(e) => return e,
231 };
232 let nanos = (secs.trunc() as i64)
233 .checked_mul(1_000_000_000)
234 .and_then(|whole| whole.checked_add((secs.fract() * 1e9).round() as i64));
235 match nanos {
236 Some(n) => zoned(ZonedInstant::from_instant(n, zone)),
237 None => Value::Error(ErrorKind::Num),
238 }
239}
240
241pub fn tzparse_fn(args: &[Value]) -> Value {
242 if let Some(e) = check_arity(args, 1, 2) {
243 return e;
244 }
245 let text = match &args[0] {
246 Value::Text(s) => s.as_str(),
247 _ => return Value::Error(ErrorKind::Value),
248 };
249 if let Some(zi) = parse_rfc9557(text) {
251 return zoned(zi);
252 }
253 if args.len() == 2 {
255 let zone = match arg_zone(&args[1]) {
256 Ok(z) => z,
257 Err(e) => return e,
258 };
259 if let Some(naive) = parse_naive_datetime(text) {
260 return match ZonedInstant::from_local(zone, naive, AmbiguousPolicy::Reject) {
261 Some(zi) => zoned(zi),
262 None => Value::Error(ErrorKind::Value),
263 };
264 }
265 }
266 Value::Error(ErrorKind::Value)
267}
268
269pub fn tzisdst_fn(args: &[Value]) -> Value {
270 if let Some(e) = check_arity(args, 1, 1) {
271 return e;
272 }
273 match arg_zoned(&args[0]) {
274 Ok(zi) => Value::Bool(zi.is_dst()),
275 Err(e) => e,
276 }
277}
278
279pub fn tzabbr_fn(args: &[Value]) -> Value {
280 if let Some(e) = check_arity(args, 1, 1) {
281 return e;
282 }
283 match arg_zoned(&args[0]) {
284 Ok(zi) => Value::Text(zi.abbrev()),
285 Err(e) => e,
286 }
287}
288
289pub fn tzoffsetdiff_fn(args: &[Value]) -> Value {
290 if let Some(e) = check_arity(args, 2, 2) {
291 return e;
292 }
293 let a = match arg_zoned(&args[0]) {
294 Ok(z) => z,
295 Err(e) => return e,
296 };
297 let b = match arg_zoned(&args[1]) {
298 Ok(z) => z,
299 Err(e) => return e,
300 };
301 Value::Number((a.offset_minutes() - b.offset_minutes()) as f64)
302}
303
304pub fn tzpart_fn(args: &[Value]) -> Value {
305 if let Some(e) = check_arity(args, 2, 2) {
306 return e;
307 }
308 let zi = match arg_zoned(&args[0]) {
309 Ok(z) => z,
310 Err(e) => return e,
311 };
312 let unit = match &args[1] {
313 Value::Text(s) => s.to_ascii_lowercase(),
314 _ => return Value::Error(ErrorKind::Value),
315 };
316 let local = zi.local();
317 let value = match unit.as_str() {
318 "year" => local.year() as f64,
319 "month" => local.month() as f64,
320 "day" => local.day() as f64,
321 "hour" => local.hour() as f64,
322 "minute" => local.minute() as f64,
323 "second" => local.second() as f64,
324 "weekday" => (local.weekday().num_days_from_sunday() + 1) as f64,
326 _ => return Value::Error(ErrorKind::Value),
327 };
328 Value::Number(value)
329}
330
331pub fn tzdiff_fn(args: &[Value]) -> Value {
332 if let Some(e) = check_arity(args, 2, 3) {
333 return e;
334 }
335 let a = match arg_zoned(&args[0]) {
336 Ok(z) => z,
337 Err(e) => return e,
338 };
339 let b = match arg_zoned(&args[1]) {
340 Ok(z) => z,
341 Err(e) => return e,
342 };
343 let unit = match args.get(2) {
344 None => "seconds".to_string(),
345 Some(Value::Text(s)) => s.to_ascii_lowercase(),
346 Some(_) => return Value::Error(ErrorKind::Value),
347 };
348 let diff_ns = a.utc_nanos as i128 - b.utc_nanos as i128;
350 let value = match unit.as_str() {
351 "nanoseconds" => diff_ns as f64,
352 "seconds" => diff_ns as f64 / 1e9,
353 "minutes" => diff_ns as f64 / 6e10,
354 "hours" => diff_ns as f64 / 3.6e12,
355 "days" => diff_ns as f64 / 8.64e13,
357 _ => return Value::Error(ErrorKind::Value),
358 };
359 Value::Number(value)
360}
361
362pub fn tzadd_fn(args: &[Value]) -> Value {
363 if let Some(e) = check_arity(args, 3, 4) {
364 return e;
365 }
366 let zi = match arg_zoned(&args[0]) {
367 Ok(z) => z,
368 Err(e) => return e,
369 };
370 let amount = match to_number(args[1].clone()) {
371 Ok(n) => n.trunc() as i64,
372 Err(e) => return e,
373 };
374 let unit = match &args[2] {
375 Value::Text(s) => s.to_ascii_lowercase(),
376 _ => return Value::Error(ErrorKind::Value),
377 };
378 let policy = match optional_policy(args.get(3)) {
379 Ok(p) => p,
380 Err(e) => return e,
381 };
382
383 match unit.as_str() {
384 "seconds" | "minutes" | "hours" => {
386 let factor: i64 = match unit.as_str() {
387 "minutes" => 60,
388 "hours" => 3600,
389 _ => 1,
390 };
391 let nanos = amount
392 .checked_mul(factor)
393 .and_then(|s| s.checked_mul(1_000_000_000))
394 .and_then(|d| zi.utc_nanos.checked_add(d));
395 match nanos {
396 Some(n) => zoned(ZonedInstant::from_instant(n, zi.zone.clone())),
397 None => Value::Error(ErrorKind::Num),
398 }
399 }
400 "days" | "weeks" | "months" | "years" => {
403 let local = zi.local();
404 let new_local = match unit.as_str() {
405 "days" => local.checked_add_signed(Duration::days(amount)),
406 "weeks" => local.checked_add_signed(Duration::weeks(amount)),
407 "months" => add_months(local, amount),
408 _ => amount.checked_mul(12).and_then(|m| add_months(local, m)), };
410 match new_local.and_then(|nl| ZonedInstant::from_local(zi.zone.clone(), nl, policy)) {
411 Some(z) => zoned(z),
412 None => Value::Error(ErrorKind::Value),
413 }
414 }
415 _ => Value::Error(ErrorKind::Value),
416 }
417}
418
419pub fn tznow_fn(args: &[Expr], ctx: &mut EvalCtx<'_>) -> Value {
423 if let Some(e) = check_arity_len(args.len(), 1, 1) {
424 return e;
425 }
426 let zone_val = evaluate_expr(&args[0], ctx);
427 let zone = match &zone_val {
428 Value::Text(s) => match parse_zone(s) {
429 Some(z) => z,
430 None => return Value::Error(ErrorKind::Value),
431 },
432 Value::Error(_) => return zone_val,
433 _ => return Value::Error(ErrorKind::Value),
434 };
435 let nanos = match ctx.ctx.now_utc_nanos {
436 Some(n) => n,
437 None => match Utc::now().timestamp_nanos_opt() {
438 Some(n) => n,
439 None => return Value::Error(ErrorKind::Num),
440 },
441 };
442 zoned(ZonedInstant::from_instant(nanos, zone))
443}
444
445pub fn tzinwindow_fn(args: &[Value]) -> Value {
451 if let Some(e) = check_arity(args, 3, 4) {
452 return e;
453 }
454 let zi = match arg_zoned(&args[0]) {
455 Ok(z) => z,
456 Err(e) => return e,
457 };
458 let start = match to_number(args[1].clone()) {
459 Ok(n) => n,
460 Err(e) => return e,
461 };
462 let end = match to_number(args[2].clone()) {
463 Ok(n) => n,
464 Err(e) => return e,
465 };
466 let local = zi.local();
467 if let Some(mask_arg) = args.get(3) {
468 let mask = match mask_arg {
469 Value::Text(s) => s,
470 _ => return Value::Error(ErrorKind::Value),
471 };
472 let idx = local.weekday().num_days_from_sunday() as usize;
473 match mask.chars().nth(idx) {
474 Some('1') => {}
475 Some(_) => return Value::Bool(false),
476 None => return Value::Error(ErrorKind::Value),
477 }
478 }
479 let frac = local.time().num_seconds_from_midnight() as f64 / 86_400.0;
480 Value::Bool(in_window(frac, start, end))
481}
482
483fn in_window(frac: f64, start: f64, end: f64) -> bool {
486 if start <= end {
487 frac >= start && frac < end
488 } else {
489 frac >= start || frac < end
490 }
491}
492
493pub fn tzoverlap_fn(args: &[Value]) -> Value {
494 if let Some(e) = check_arity(args, 4, 5) {
495 return e;
496 }
497 let names = match collect_zone_names(&args[0]) {
498 Ok(n) => n,
499 Err(e) => return e,
500 };
501 let start = match to_number(args[1].clone()) {
502 Ok(n) => n,
503 Err(e) => return e,
504 };
505 let end = match to_number(args[2].clone()) {
506 Ok(n) => n,
507 Err(e) => return e,
508 };
509 let date_serial = match to_number(args[3].clone()) {
510 Ok(n) => n,
511 Err(e) => return e,
512 };
513 let gran_min = match args.get(4) {
514 None => 30.0,
515 Some(v) => match to_number(v.clone()) {
516 Ok(n) => n,
517 Err(e) => return e,
518 },
519 };
520 if gran_min < 1.0 {
521 return Value::Error(ErrorKind::Value);
522 }
523 let zones: Vec<ZoneId> = match names.iter().map(|n| parse_zone(n).ok_or(())).collect() {
524 Ok(z) => z,
525 Err(()) => return Value::Error(ErrorKind::Value),
526 };
527 let first = zones[0].clone();
528
529 let date = match serial_to_date(date_serial) {
533 Some(d) => d,
534 None => return Value::Error(ErrorKind::Value),
535 };
536 let midnight = match date.and_hms_opt(0, 0, 0) {
537 Some(m) => m,
538 None => return Value::Error(ErrorKind::Value),
539 };
540 let scan_start = match ZonedInstant::from_local(first.clone(), midnight, AmbiguousPolicy::Compatible) {
541 Some(z) => z.utc_nanos,
542 None => return Value::Error(ErrorKind::Value),
543 };
544 let step = (gran_min * 60.0 * 1e9) as i64;
545 let scan_end = scan_start + 48 * 3_600 * 1_000_000_000;
546
547 let mut overlap_start: Option<i64> = None;
548 let mut overlap_last: Option<i64> = None;
549 let mut t = scan_start;
550 while t <= scan_end {
551 let all_in = zones.iter().all(|z| {
552 let local = ZonedInstant::from_instant(t, z.clone()).local();
553 let frac = local.time().num_seconds_from_midnight() as f64 / 86_400.0;
554 in_window(frac, start, end)
555 });
556 if all_in {
557 overlap_start.get_or_insert(t);
558 overlap_last = Some(t);
559 } else if overlap_start.is_some() {
560 break; }
562 t += step;
563 }
564
565 match (overlap_start, overlap_last) {
566 (Some(s), Some(last)) => Value::Array(vec![
567 zoned(ZonedInstant::from_instant(s, first.clone())),
568 zoned(ZonedInstant::from_instant(last + step, first)),
570 ]),
571 _ => Value::Text("No overlap".to_string()),
572 }
573}
574
575pub fn tzcanonical_fn(args: &[Value]) -> Value {
580 if let Some(e) = check_arity(args, 1, 1) {
581 return e;
582 }
583 match &args[0] {
584 Value::Text(s) => match parse_zone(s) {
585 Some(ZoneId::Iana(tz)) => Value::Text(tz.name().to_string()),
586 Some(ZoneId::Fixed(m)) => {
587 let a = m.abs();
588 Value::Text(format!("{}{:02}:{:02}", if m < 0 { '-' } else { '+' }, a / 60, a % 60))
589 }
590 None => Value::Error(ErrorKind::Value),
591 },
592 _ => Value::Error(ErrorKind::Value),
593 }
594}
595
596pub fn tzlocalstatus_fn(args: &[Value]) -> Value {
597 if let Some(e) = check_arity(args, 6, 6) {
598 return e;
599 }
600 let parts = match (0..5).map(|i| int_arg(&args[i])).collect::<Result<Vec<_>, _>>() {
601 Ok(p) => p,
602 Err(e) => return e,
603 };
604 let zone = match arg_zone(&args[5]) {
605 Ok(z) => z,
606 Err(e) => return e,
607 };
608 let naive = match build_naive(parts[0], parts[1], parts[2], parts[3], parts[4], 0) {
609 Some(n) => n,
610 None => return Value::Error(ErrorKind::Value),
611 };
612 let status = match zone {
613 ZoneId::Fixed(_) => "unique",
614 ZoneId::Iana(tz) => {
615 use chrono::LocalResult;
616 match tz.from_local_datetime(&naive) {
617 LocalResult::Single(_) => "unique",
618 LocalResult::Ambiguous(_, _) => "fold",
619 LocalResult::None => "gap",
620 }
621 }
622 };
623 Value::Text(status.to_string())
624}
625
626pub fn tztext_fn(args: &[Value]) -> Value {
627 if let Some(e) = check_arity(args, 1, 2) {
628 return e;
629 }
630 let zi = match arg_zoned(&args[0]) {
631 Ok(z) => z,
632 Err(e) => return e,
633 };
634 let local = zi.local();
635 match args.get(1) {
636 None => Value::Text(format!("{} {}", local.format("%a %b %d %Y %H:%M"), zi.abbrev())),
637 Some(Value::Text(fmt)) => {
638 if StrftimeItems::new(fmt).any(|item| matches!(item, Item::Error)) {
639 return Value::Error(ErrorKind::Value);
640 }
641 Value::Text(local.format(fmt).to_string())
642 }
643 Some(_) => Value::Error(ErrorKind::Value),
644 }
645}
646
647pub fn tzboard_fn(args: &[Value]) -> Value {
648 if let Some(e) = check_arity(args, 2, 3) {
649 return e;
650 }
651 let anchor = match arg_zoned(&args[0]) {
652 Ok(z) => z,
653 Err(e) => return e,
654 };
655 let instant = anchor.utc_nanos;
656 let names = match collect_zone_names(&args[1]) {
657 Ok(n) => n,
658 Err(e) => return e,
659 };
660 let base_zone = match args.get(2) {
661 None => anchor.zone.clone(),
662 Some(v) => match zone_of_arg(v) {
663 Ok(z) => z,
664 Err(e) => return e,
665 },
666 };
667 let base = ZonedInstant::from_instant(instant, base_zone);
668 let base_offset = base.offset_minutes();
669 let base_date = base.local().date();
670
671 let mut rows = Vec::with_capacity(names.len());
672 for name in &names {
673 let zone = match parse_zone(name) {
674 Some(z) => z,
675 None => return Value::Error(ErrorKind::Value),
676 };
677 let zi = ZonedInstant::from_instant(instant, zone);
678 let offset = zi.offset_minutes();
679 let rollover = zi.local().date().signed_duration_since(base_date).num_days();
680 rows.push(Value::Array(vec![
681 Value::Text(name.clone()),
682 Value::Text(zi.to_rfc9557()),
683 Value::Number(offset as f64),
684 Value::Number((offset - base_offset) as f64),
685 Value::Number(rollover as f64),
686 Value::Text(zi.abbrev()),
687 Value::Bool(zi.is_dst()),
688 ]));
689 }
690 Value::Array(rows)
691}
692
693pub fn tzworldclock_fn(args: &[Value]) -> Value {
694 if let Some(e) = check_arity(args, 2, 3) {
695 return e;
696 }
697 let anchor = match arg_zoned(&args[0]) {
698 Ok(z) => z,
699 Err(e) => return e,
700 };
701 let instant = anchor.utc_nanos;
702 let names = match collect_zone_names(&args[1]) {
703 Ok(n) => n,
704 Err(e) => return e,
705 };
706 let base_zone = match args.get(2) {
707 None => anchor.zone.clone(),
708 Some(v) => match zone_of_arg(v) {
709 Ok(z) => z,
710 Err(e) => return e,
711 },
712 };
713 let base = ZonedInstant::from_instant(instant, base_zone.clone());
714 let base_offset = base.offset_minutes();
715 let base_date = base.local().date();
716
717 let mut out = format!(
718 "It is {} {} for you ({}).",
719 base.local().format("%H:%M"),
720 base.local().format("%a"),
721 zone_label(&base_zone),
722 );
723 for name in &names {
724 let zone = match parse_zone(name) {
725 Some(z) => z,
726 None => return Value::Error(ErrorKind::Value),
727 };
728 let zi = ZonedInstant::from_instant(instant, zone);
729 let delta = zi.offset_minutes() - base_offset;
730 let rollover = zi.local().date().signed_duration_since(base_date).num_days();
731 out.push_str(&format!(
732 " {} is {} ({}{}).",
733 name,
734 friendly_delta(delta),
735 zi.local().format("%H:%M"),
736 day_phrase(rollover),
737 ));
738 }
739 Value::Text(out)
740}
741
742fn build_naive(y: i64, mo: i64, d: i64, h: i64, mi: i64, s: i64) -> Option<NaiveDateTime> {
744 let y = i32::try_from(y).ok()?;
745 let mo = u32::try_from(mo).ok()?;
746 let d = u32::try_from(d).ok()?;
747 let h = u32::try_from(h).ok()?;
748 let mi = u32::try_from(mi).ok()?;
749 let s = u32::try_from(s).ok()?;
750 NaiveDate::from_ymd_opt(y, mo, d)?.and_hms_opt(h, mi, s)
751}
752
753fn collect_zone_names(v: &Value) -> Result<Vec<String>, Value> {
756 fn walk(v: &Value, out: &mut Vec<String>) -> Result<(), Value> {
757 match v {
758 Value::Text(s) => {
759 out.push(s.clone());
760 Ok(())
761 }
762 Value::Array(items) => {
763 for it in items {
764 walk(it, out)?;
765 }
766 Ok(())
767 }
768 Value::Error(_) => Err(v.clone()),
769 _ => Err(Value::Error(ErrorKind::Value)),
770 }
771 }
772 let mut out = Vec::new();
773 walk(v, &mut out)?;
774 if out.is_empty() {
775 return Err(Value::Error(ErrorKind::Value));
776 }
777 Ok(out)
778}
779
780fn zone_of_arg(v: &Value) -> Result<ZoneId, Value> {
782 match v {
783 Value::Text(s) => parse_zone(s).ok_or(Value::Error(ErrorKind::Value)),
784 Value::Zoned(z) => Ok(z.zone.clone()),
785 _ => Err(Value::Error(ErrorKind::Value)),
786 }
787}
788
789fn zone_label(zone: &ZoneId) -> String {
791 match zone {
792 ZoneId::Iana(tz) => tz.name().to_string(),
793 ZoneId::Fixed(m) => {
794 let a = m.abs();
795 format!("{}{:02}:{:02}", if *m < 0 { '-' } else { '+' }, a / 60, a % 60)
796 }
797 }
798}
799
800fn friendly_delta(minutes: i32) -> String {
802 if minutes == 0 {
803 return "the same time".to_string();
804 }
805 let a = minutes.abs();
806 let mag = if a % 60 == 0 {
807 format!("{}h", a / 60)
808 } else {
809 format!("{}:{:02}", a / 60, a % 60)
810 };
811 format!("{} {}", mag, if minutes > 0 { "ahead" } else { "behind" })
812}
813
814fn day_phrase(days: i64) -> String {
816 match days {
817 0 => String::new(),
818 1 => ", next day".to_string(),
819 -1 => ", previous day".to_string(),
820 n if n > 0 => format!(", +{n} days"),
821 n => format!(", {n} days"),
822 }
823}
824
825fn add_months(dt: NaiveDateTime, months: i64) -> Option<NaiveDateTime> {
827 if months >= 0 {
828 u32::try_from(months).ok().and_then(|m| dt.checked_add_months(Months::new(m)))
829 } else {
830 u32::try_from(-months).ok().and_then(|m| dt.checked_sub_months(Months::new(m)))
831 }
832}
833
834fn optional_policy(arg: Option<&Value>) -> Result<AmbiguousPolicy, Value> {
837 match arg {
838 None => Ok(AmbiguousPolicy::Reject),
839 Some(Value::Text(s)) => parse_policy(s).ok_or(Value::Error(ErrorKind::Value)),
840 Some(_) => Err(Value::Error(ErrorKind::Value)),
841 }
842}
843
844fn serial_to_naive(serial: f64) -> Option<NaiveDateTime> {
847 let date = serial_to_date(serial)?;
848 let (h, m, s) = serial_to_time(serial);
849 date.and_hms_opt(h, m, s)
850}
851
852fn parse_naive_datetime(s: &str) -> Option<NaiveDateTime> {
854 let s = s.trim();
855 for fmt in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M"] {
856 if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) {
857 return Some(dt);
858 }
859 }
860 NaiveDate::parse_from_str(s, "%Y-%m-%d")
861 .ok()
862 .and_then(|d| d.and_hms_opt(0, 0, 0))
863}
864
865fn parse_policy(s: &str) -> Option<AmbiguousPolicy> {
866 match s.to_ascii_lowercase().as_str() {
867 "reject" => Some(AmbiguousPolicy::Reject),
868 "earliest" => Some(AmbiguousPolicy::Earliest),
869 "latest" => Some(AmbiguousPolicy::Latest),
870 "compatible" => Some(AmbiguousPolicy::Compatible),
871 _ => None,
872 }
873}