Skip to main content

truecalc_core/eval/functions/timezone/
mod.rs

1//! Timezone-aware functions (Model B). They construct, convert, introspect and
2//! display [`Value::Zoned`] instants. The naive<->zoned boundary is always an
3//! explicit call here: `TZDATETIME`/`TZLOCALIZE` up, `TZSERIAL` down.
4
5use 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
51// ── Shared helpers ────────────────────────────────────────────────────────────
52
53/// Borrow a [`ZonedInstant`] from a `Zoned` argument, else `#VALUE!`.
54fn 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
61/// Parse a zone from a text argument, else `#VALUE!`.
62fn 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
69/// Coerce an argument to a truncated integer, propagating coercion errors.
70fn 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
78// ── Functions ─────────────────────────────────────────────────────────────────
79
80pub 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    // Offset-bearing or bracketed strings are self-describing.
250    if let Some(zi) = parse_rfc9557(text) {
251        return zoned(zi);
252    }
253    // A naive datetime needs an explicit zone to become an instant.
254    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        // Sheets WEEKDAY default: Sunday = 1 .. Saturday = 7.
325        "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    // i128 avoids overflow on the difference of two i64 nanosecond counts.
349    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        // Absolute days = 86400 s; calendar-day length is a TZADD concern.
356        "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        // Absolute units shift the instant directly (DST-immune).
385        "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        // Calendar units shift the wall clock then re-resolve (DST-aware): a
401        // calendar day may be 23 or 25 hours across a transition.
402        "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)), // years
409            };
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
419/// `TZNOW(zone)` — the current instant stamped with `zone`. Volatile and lazy:
420/// it reads the pinned UTC instant from the context (set during recalc), falling
421/// back to the ambient UTC clock when unpinned.
422pub 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
445/// `TZINWINDOW(zoned, start_local, end_local, [days_mask])` — is the local
446/// time-of-day within `[start, end)`? `start`/`end` are time-of-day fractions
447/// (0..1, e.g. `TIME(9,0,0)`). An overnight window (`start > end`) wraps past
448/// midnight. Optional `days_mask` is 7 characters, index 0 = Sunday, '1' = the
449/// day counts.
450pub 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
483/// Time-of-day membership in `[start, end)`; an overnight window (`start > end`)
484/// wraps past midnight.
485fn 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    // Anchor the scan at local midnight of `date_serial` in the first zone, then
530    // step across the next 48 hours looking for the first contiguous run where
531    // every zone is within the window.
532    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; // end of the first contiguous overlap run
561        }
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            // Exclusive end: one granularity step past the last in-window sample.
569            zoned(ZonedInstant::from_instant(last + step, first)),
570        ]),
571        _ => Value::Text("No overlap".to_string()),
572    }
573}
574
575/// `TZCANONICAL(zone)` — validate and normalize a zone name. Best-effort: a
576/// valid IANA name or fixed offset is returned in canonical form, but chrono-tz
577/// keeps backward-compat links (e.g. `US/Pacific`) as distinct names, so links
578/// are not resolved to their target.
579pub 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
742/// Compose date/time integer parts into a `NaiveDateTime`, validating ranges.
743fn 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
753/// Collect zone-name strings from a value, flattening arrays. Errors on any
754/// non-text leaf or an empty list.
755fn 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
780/// Resolve a base-zone argument: a zone name, or an existing zoned instant.
781fn 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
789/// Display label for a zone: IANA name or `±HH:MM`.
790fn 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
800/// Friendly offset delta, e.g. `3h ahead`, `5:45 ahead`, `2h behind`.
801fn 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
814/// Calendar-day rollover phrase relative to the base zone.
815fn 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
825/// Add (or subtract, when negative) a whole number of calendar months.
826fn 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
834/// Resolve the optional ambiguous-policy argument shared by the construction
835/// functions. Absent ⇒ [`AmbiguousPolicy::Reject`]; non-text or unknown ⇒ `#VALUE!`.
836fn 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
844/// Compose a date serial into a `NaiveDateTime` (integer part = date, fractional
845/// part = time of day).
846fn 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
852/// Parse a zone-less ISO-ish datetime (or bare date) into a `NaiveDateTime`.
853fn 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}