Skip to main content

r/interpreter/builtins/
datetime.rs

1//! Date and time builtins: Sys.Date, as.Date, as.POSIXct, format.Date, etc.
2//!
3//! Uses the `jiff` crate for timezone-aware date/time operations.
4//! R stores dates as doubles with class attributes:
5//! - Date: days since 1970-01-01
6//! - POSIXct: seconds since epoch, class = c("POSIXct", "POSIXt")
7
8use derive_more::{Display, Error};
9use jiff::civil::Date;
10use jiff::Timestamp;
11
12use crate::interpreter::value::*;
13use crate::interpreter::BuiltinContext;
14use minir_macros::{builtin, interpreter_builtin};
15
16// region: DateTimeError
17
18#[derive(Debug, Display, Error)]
19pub enum DateTimeError {
20    #[display("character string is not in a standard unambiguous format")]
21    AmbiguousFormat,
22
23    #[display("invalid 'origin' argument")]
24    InvalidOrigin,
25
26    #[display("invalid date/time format: {}", reason)]
27    InvalidFormat { reason: String },
28}
29
30impl From<DateTimeError> for RError {
31    fn from(e: DateTimeError) -> Self {
32        RError::from_source(RErrorKind::Argument, e)
33    }
34}
35
36// endregion
37
38// region: helpers
39
40/// The Unix epoch as a jiff Date.
41const EPOCH: Date = Date::constant(1970, 1, 1);
42
43/// Convert days-since-epoch (f64) to a jiff Date.
44fn days_to_date(days: f64) -> Option<Date> {
45    let days_i32 = days.round() as i32; // f64→i32: no TryFrom in std; valid dates always fit
46    EPOCH
47        .checked_add(jiff::Span::new().days(i64::from(days_i32)))
48        .ok()
49}
50
51/// Convert a jiff Date to days-since-epoch (f64).
52fn date_to_days(date: Date) -> f64 {
53    // until() returns Result but cannot fail for two valid Dates with default config
54    let span = EPOCH.until(date).expect("valid date span");
55    f64::from(span.get_days())
56}
57
58/// Build an RValue with class = "Date".
59fn r_date(days_since_epoch: f64) -> RValue {
60    let mut rv = RVector::from(Vector::Double(vec![Some(days_since_epoch)].into()));
61    rv.set_attr(
62        "class".to_string(),
63        RValue::vec(Vector::Character(vec![Some("Date".to_string())].into())),
64    );
65    rv.into()
66}
67
68/// Build an RValue with class = c("POSIXct", "POSIXt").
69fn r_posixct(secs_since_epoch: f64, tz: Option<&str>) -> RValue {
70    let mut rv = RVector::from(Vector::Double(vec![Some(secs_since_epoch)].into()));
71    rv.set_attr(
72        "class".to_string(),
73        RValue::vec(Vector::Character(
74            vec![Some("POSIXct".to_string()), Some("POSIXt".to_string())].into(),
75        )),
76    );
77    if let Some(tz) = tz {
78        rv.set_attr(
79            "tzone".to_string(),
80            RValue::vec(Vector::Character(vec![Some(tz.to_string())].into())),
81        );
82    }
83    rv.into()
84}
85
86/// Build a POSIXct vector from multiple seconds values.
87fn r_posixct_vec(secs: Vec<Option<f64>>, tz: Option<&str>) -> RValue {
88    let mut rv = RVector::from(Vector::Double(secs.into()));
89    rv.set_attr(
90        "class".to_string(),
91        RValue::vec(Vector::Character(
92            vec![Some("POSIXct".to_string()), Some("POSIXt".to_string())].into(),
93        )),
94    );
95    if let Some(tz) = tz {
96        rv.set_attr(
97            "tzone".to_string(),
98            RValue::vec(Vector::Character(vec![Some(tz.to_string())].into())),
99        );
100    }
101    rv.into()
102}
103
104/// Map R strftime codes to jiff strftime codes.
105/// Most are identical; R's %OS (fractional seconds) maps to %S%.f.
106fn translate_format(fmt: &str) -> String {
107    fmt.replace("%OS", "%S%.f")
108}
109
110/// Extract seconds-since-epoch from a Timestamp.
111fn timestamp_to_secs(ts: &Timestamp) -> f64 {
112    // i64→f64: epoch seconds always fit in f64 mantissa
113    (ts.as_second() as f64) + f64::from(ts.subsec_nanosecond()) / 1e9
114}
115
116/// Try common date formats in order.
117fn parse_date_string(s: &str) -> Result<Date, DateTimeError> {
118    // ISO 8601 first
119    if let Ok(d) = s.parse::<Date>() {
120        return Ok(d);
121    }
122    // Try common R formats
123    for fmt in &["%Y-%m-%d", "%Y/%m/%d", "%m/%d/%Y", "%d/%m/%Y", "%b %d, %Y"] {
124        if let Ok(d) = Date::strptime(fmt, s) {
125            return Ok(d);
126        }
127    }
128    Err(DateTimeError::AmbiguousFormat)
129}
130
131/// Try common datetime formats.
132fn parse_datetime_string(s: &str, tz: Option<&str>) -> Result<f64, DateTimeError> {
133    // Try parsing as Zoned first (has timezone info)
134    if let Ok(z) = s.parse::<jiff::Zoned>() {
135        return Ok(timestamp_to_secs(&z.timestamp()));
136    }
137    // Try as Timestamp
138    if let Ok(ts) = s.parse::<Timestamp>() {
139        return Ok(timestamp_to_secs(&ts));
140    }
141    // Try as civil DateTime, then apply timezone
142    let fmts = [
143        "%Y-%m-%d %H:%M:%S",
144        "%Y-%m-%d %H:%M",
145        "%Y/%m/%d %H:%M:%S",
146        "%Y-%m-%dT%H:%M:%S",
147    ];
148    for fmt in &fmts {
149        if let Ok(dt) = jiff::civil::DateTime::strptime(fmt, s) {
150            let zoned = civil_to_zoned(dt, tz)?;
151            return Ok(timestamp_to_secs(&zoned.timestamp()));
152        }
153    }
154    // Try as date-only, treating as midnight
155    if let Ok(d) = parse_date_string(s) {
156        let dt = d.at(0, 0, 0, 0);
157        let zoned = civil_to_zoned(dt, tz)?;
158        return Ok(timestamp_to_secs(&zoned.timestamp()));
159    }
160    Err(DateTimeError::AmbiguousFormat)
161}
162
163/// Convert a civil DateTime to a Zoned datetime in the given timezone.
164fn civil_to_zoned(
165    dt: jiff::civil::DateTime,
166    tz: Option<&str>,
167) -> Result<jiff::Zoned, DateTimeError> {
168    let tz_name = tz.unwrap_or("UTC");
169    let tz_obj = jiff::tz::TimeZone::get(tz_name).map_err(|_| DateTimeError::InvalidFormat {
170        reason: format!("unknown timezone '{tz_name}'"),
171    })?;
172    dt.to_zoned(tz_obj)
173        .map_err(|e| DateTimeError::InvalidFormat {
174            reason: e.to_string(),
175        })
176}
177
178/// Build a Date vector with class attr from days values.
179fn r_date_vec(days: Vec<Option<f64>>) -> RValue {
180    let mut rv = RVector::from(Vector::Double(days.into()));
181    rv.set_attr(
182        "class".to_string(),
183        RValue::vec(Vector::Character(vec![Some("Date".to_string())].into())),
184    );
185    rv.into()
186}
187
188// endregion
189
190// region: Sys.Date / Sys.time
191
192/// Get the current date.
193///
194/// @return a Date object representing today's date
195#[builtin(name = "Sys.Date")]
196fn builtin_sys_date(_args: &[RValue], _named: &[(String, RValue)]) -> Result<RValue, RError> {
197    let today = jiff::Zoned::now().date();
198    Ok(r_date(date_to_days(today)))
199}
200
201/// Get the current date-time as a POSIXct value.
202///
203/// @return a POSIXct object representing the current instant
204#[builtin(name = "Sys.time")]
205fn builtin_sys_time(_args: &[RValue], _named: &[(String, RValue)]) -> Result<RValue, RError> {
206    let ts = Timestamp::now();
207    Ok(r_posixct(timestamp_to_secs(&ts), None))
208}
209
210/// Return the current date and time as a character string.
211///
212/// The format matches R's date(): "Wed Mar 18 12:00:00 2026"
213/// (ctime-style: abbreviated weekday, month, day, HH:MM:SS, 4-digit year).
214///
215/// @return character string representing the current date and time
216#[builtin(name = "date")]
217fn builtin_date(_args: &[RValue], _named: &[(String, RValue)]) -> Result<RValue, RError> {
218    let now = jiff::Zoned::now();
219    // R's date() uses ctime format: "%a %b %d %H:%M:%S %Y"
220    let formatted = now.strftime("%a %b %d %H:%M:%S %Y").to_string();
221    Ok(RValue::vec(Vector::Character(vec![Some(formatted)].into())))
222}
223
224// endregion
225
226// region: print.Date / print.POSIXct
227
228/// Print a Date object to stdout.
229///
230/// @param x a Date object to print
231/// @return x, invisibly
232#[interpreter_builtin(name = "print.Date", min_args = 1)]
233fn interp_print_date(
234    args: &[RValue],
235    named: &[(String, RValue)],
236    context: &BuiltinContext,
237) -> Result<RValue, RError> {
238    let formatted = builtin_format_date(args, named)?;
239    context.write(&format!("{}\n", formatted));
240    Ok(args.first().cloned().unwrap_or(RValue::Null))
241}
242
243/// Print a POSIXct object to stdout.
244///
245/// @param x a POSIXct object to print
246/// @return x, invisibly
247#[interpreter_builtin(name = "print.POSIXct", min_args = 1)]
248fn interp_print_posixct(
249    args: &[RValue],
250    named: &[(String, RValue)],
251    context: &BuiltinContext,
252) -> Result<RValue, RError> {
253    let formatted = builtin_format_posixct(args, named)?;
254    context.write(&format!("{}\n", formatted));
255    Ok(args.first().cloned().unwrap_or(RValue::Null))
256}
257
258// endregion
259
260// region: as.Date
261
262/// Convert a character string or numeric value to a Date object.
263///
264/// @param x character string, numeric (days since origin), or Date to convert
265/// @param format strptime-style format string for parsing (optional)
266/// @param origin Date or string giving the origin for numeric conversion
267/// @return a Date object
268#[builtin(name = "as.Date", min_args = 1)]
269fn builtin_as_date(args: &[RValue], named: &[(String, RValue)]) -> Result<RValue, RError> {
270    let x = &args[0];
271
272    // Check for format argument
273    let format = named
274        .iter()
275        .find(|(k, _)| k == "format")
276        .map(|(_, v)| v)
277        .or(args.get(1))
278        .and_then(|v| v.as_vector())
279        .and_then(|v| v.as_character_scalar());
280
281    // Check for origin argument (for numeric conversion)
282    let origin = named
283        .iter()
284        .find(|(k, _)| k == "origin")
285        .map(|(_, v)| v)
286        .or(args.get(1));
287
288    match x {
289        RValue::Vector(rv) => {
290            // Check if already a Date
291            if let Some(cls) = rv.get_attr("class") {
292                if let Some(c) = cls.as_vector().and_then(|v| v.as_character_scalar()) {
293                    if c == "Date" {
294                        return Ok(x.clone());
295                    }
296                }
297            }
298
299            match &rv.inner {
300                Vector::Character(cv) => {
301                    // Parse string(s) to date
302                    let days: Vec<Option<f64>> = cv
303                        .iter()
304                        .map(|opt_s| {
305                            opt_s
306                                .as_ref()
307                                .map(|s| {
308                                    if let Some(ref fmt) = format {
309                                        let jiff_fmt = translate_format(fmt);
310                                        Date::strptime(&jiff_fmt, s)
311                                            .map(date_to_days)
312                                            .map_err(|_| DateTimeError::AmbiguousFormat)
313                                    } else {
314                                        parse_date_string(s).map(date_to_days)
315                                    }
316                                })
317                                .transpose()
318                        })
319                        .collect::<Result<_, _>>()?;
320                    Ok(r_date_vec(days))
321                }
322                Vector::Double(dv) => {
323                    // Numeric: needs origin
324                    let origin_days = resolve_origin(origin)?;
325                    let days: Vec<Option<f64>> = dv
326                        .iter()
327                        .map(|opt_d| opt_d.map(|d| d + origin_days))
328                        .collect();
329                    Ok(r_date_vec(days))
330                }
331                Vector::Integer(iv) => {
332                    // Integer: same as double, needs origin
333                    let origin_days = resolve_origin(origin)?;
334                    let days: Vec<Option<f64>> = iv
335                        .iter_opt()
336                        .map(|opt_i| opt_i.map(|i| i as f64 + origin_days))
337                        .collect();
338                    Ok(r_date_vec(days))
339                }
340                _ => Err(RError::new(
341                    RErrorKind::Type,
342                    format!(
343                        "expected character or numeric, got {}",
344                        rv.inner.type_name()
345                    ),
346                )),
347            }
348        }
349        _ => Err(RError::new(
350            RErrorKind::Type,
351            format!("expected character or numeric, got {}", x.type_name()),
352        )),
353    }
354}
355
356/// Resolve the 'origin' argument for numeric→Date conversion.
357fn resolve_origin(origin: Option<&RValue>) -> Result<f64, RError> {
358    if let Some(orig) = origin {
359        if let Some(s) = orig.as_vector().and_then(|v| v.as_character_scalar()) {
360            let d = parse_date_string(&s).map_err(|_| DateTimeError::InvalidOrigin)?;
361            Ok(date_to_days(d))
362        } else if let Some(d) = orig.as_vector().and_then(|v| v.as_double_scalar()) {
363            Ok(d)
364        } else {
365            Err(DateTimeError::InvalidOrigin.into())
366        }
367    } else {
368        Err(RError::other(
369            "'origin' must be supplied for numeric conversion",
370        ))
371    }
372}
373
374// endregion
375
376// region: as.POSIXct
377
378/// Convert a character string or numeric value to a POSIXct date-time.
379///
380/// @param x character string, numeric (seconds since epoch), or POSIXct to convert
381/// @param tz timezone name (default: UTC for parsing, system for display)
382/// @return a POSIXct object
383#[builtin(name = "as.POSIXct", min_args = 1)]
384fn builtin_as_posixct(args: &[RValue], named: &[(String, RValue)]) -> Result<RValue, RError> {
385    let x = &args[0];
386
387    let tz = named
388        .iter()
389        .find(|(k, _)| k == "tz")
390        .map(|(_, v)| v)
391        .and_then(|v| v.as_vector())
392        .and_then(|v| v.as_character_scalar());
393
394    match x {
395        RValue::Vector(rv) => {
396            // If already POSIXct, return as-is
397            if let Some(cls) = rv.get_attr("class") {
398                if let Some(c) = cls.as_vector().and_then(|v| v.as_character_scalar()) {
399                    if c == "POSIXct" {
400                        return Ok(x.clone());
401                    }
402                }
403            }
404
405            match &rv.inner {
406                Vector::Character(cv) => {
407                    let secs: Vec<Option<f64>> = cv
408                        .iter()
409                        .map(|opt_s| {
410                            opt_s
411                                .as_ref()
412                                .map(|s| parse_datetime_string(s, tz.as_deref()))
413                                .transpose()
414                        })
415                        .collect::<Result<_, _>>()?;
416                    Ok(r_posixct_vec(secs, tz.as_deref()))
417                }
418                Vector::Double(dv) => {
419                    // Numeric: treat as seconds since epoch
420                    let secs: Vec<Option<f64>> = dv.iter_opt().collect();
421                    Ok(r_posixct_vec(secs, tz.as_deref()))
422                }
423                _ => Err(RError::new(
424                    RErrorKind::Type,
425                    format!(
426                        "expected character or numeric, got {}",
427                        rv.inner.type_name()
428                    ),
429                )),
430            }
431        }
432        _ => Err(RError::new(
433            RErrorKind::Type,
434            format!("expected character or numeric, got {}", x.type_name()),
435        )),
436    }
437}
438
439// endregion
440
441// region: format.Date / format.POSIXct
442
443/// Format a Date object as a character string.
444///
445/// @param x a Date object
446/// @param format strftime-style format string (default "%Y-%m-%d")
447/// @return character vector of formatted date strings
448#[builtin(name = "format.Date", min_args = 1)]
449fn builtin_format_date(args: &[RValue], named: &[(String, RValue)]) -> Result<RValue, RError> {
450    let x = &args[0];
451    let format = named
452        .iter()
453        .find(|(k, _)| k == "format")
454        .map(|(_, v)| v)
455        .or(args.get(1))
456        .and_then(|v| v.as_vector())
457        .and_then(|v| v.as_character_scalar())
458        .unwrap_or_else(|| "%Y-%m-%d".to_string());
459
460    let jiff_fmt = translate_format(&format);
461
462    match x {
463        RValue::Vector(rv) => match &rv.inner {
464            Vector::Double(vals) => {
465                let result: Vec<Option<String>> = vals
466                    .iter_opt()
467                    .map(|opt_d| {
468                        opt_d.and_then(|d| {
469                            days_to_date(d).map(|date| date.strftime(&jiff_fmt).to_string())
470                        })
471                    })
472                    .collect();
473                Ok(RValue::vec(Vector::Character(result.into())))
474            }
475            _ => Err(RError::new(
476                RErrorKind::Type,
477                format!("expected Date (numeric), got {}", rv.inner.type_name()),
478            )),
479        },
480        _ => Err(RError::new(
481            RErrorKind::Type,
482            format!("expected Date, got {}", x.type_name()),
483        )),
484    }
485}
486
487/// Format a POSIXct object as a character string.
488///
489/// @param x a POSIXct object
490/// @param format strftime-style format string (default "%Y-%m-%d %H:%M:%S")
491/// @return character vector of formatted datetime strings
492#[builtin(name = "format.POSIXct", min_args = 1)]
493fn builtin_format_posixct(args: &[RValue], named: &[(String, RValue)]) -> Result<RValue, RError> {
494    let x = &args[0];
495    let format = named
496        .iter()
497        .find(|(k, _)| k == "format")
498        .map(|(_, v)| v)
499        .or(args.get(1))
500        .and_then(|v| v.as_vector())
501        .and_then(|v| v.as_character_scalar())
502        .unwrap_or_else(|| "%Y-%m-%d %H:%M:%S".to_string());
503
504    let jiff_fmt = translate_format(&format);
505
506    // Get timezone from attribute
507    let tz_name = if let RValue::Vector(rv) = x {
508        rv.get_attr("tzone")
509            .and_then(|v| v.as_vector())
510            .and_then(|v| v.as_character_scalar())
511    } else {
512        None
513    };
514
515    let tz = if let Some(ref tz_name) = tz_name {
516        jiff::tz::TimeZone::get(tz_name).unwrap_or(jiff::tz::TimeZone::UTC)
517    } else {
518        jiff::tz::TimeZone::system()
519    };
520
521    match x {
522        RValue::Vector(rv) => match &rv.inner {
523            Vector::Double(vals) => {
524                let result: Vec<Option<String>> = vals
525                    .iter_opt()
526                    .map(|opt_d| {
527                        opt_d.and_then(|secs| {
528                            secs_to_timestamp(secs)
529                                .map(|ts| ts.to_zoned(tz.clone()).strftime(&jiff_fmt).to_string())
530                        })
531                    })
532                    .collect();
533                Ok(RValue::vec(Vector::Character(result.into())))
534            }
535            _ => Err(RError::new(
536                RErrorKind::Type,
537                format!("expected POSIXct (numeric), got {}", rv.inner.type_name()),
538            )),
539        },
540        _ => Err(RError::new(
541            RErrorKind::Type,
542            format!("expected POSIXct, got {}", x.type_name()),
543        )),
544    }
545}
546
547/// Convert seconds-since-epoch (f64) to a jiff Timestamp.
548fn secs_to_timestamp(secs: f64) -> Option<Timestamp> {
549    let whole = secs.floor() as i64; // f64→i64: epoch seconds always fit
550    let nanos = ((secs - secs.floor()) * 1e9) as i32;
551    Timestamp::new(whole, nanos).ok()
552}
553
554// endregion
555
556// region: strptime / strftime
557
558/// Parse a character string into a POSIXct date-time using a format specification.
559///
560/// @param x character string to parse
561/// @param format strptime-style format string
562/// @param tz timezone name (default: UTC)
563/// @return a POSIXct object
564#[builtin(min_args = 2)]
565fn builtin_strptime(args: &[RValue], named: &[(String, RValue)]) -> Result<RValue, RError> {
566    let x = args[0]
567        .as_vector()
568        .and_then(|v| v.as_character_scalar())
569        .ok_or_else(|| {
570            RError::new(
571                RErrorKind::Type,
572                format!("expected character, got {}", args[0].type_name()),
573            )
574        })?;
575
576    let format = named
577        .iter()
578        .find(|(k, _)| k == "format")
579        .map(|(_, v)| v)
580        .or(args.get(1))
581        .and_then(|v| v.as_vector())
582        .and_then(|v| v.as_character_scalar())
583        .ok_or_else(|| RError::new(RErrorKind::Type, "expected character format".to_string()))?;
584
585    let tz = named
586        .iter()
587        .find(|(k, _)| k == "tz")
588        .map(|(_, v)| v)
589        .and_then(|v| v.as_vector())
590        .and_then(|v| v.as_character_scalar());
591
592    let jiff_fmt = translate_format(&format);
593
594    // Try parsing as datetime first, then as date
595    if let Ok(dt) = jiff::civil::DateTime::strptime(&jiff_fmt, &x) {
596        let zoned = civil_to_zoned(dt, tz.as_deref())?;
597        let secs = timestamp_to_secs(&zoned.timestamp());
598        return Ok(r_posixct(secs, tz.as_deref()));
599    }
600
601    if let Ok(d) = Date::strptime(&jiff_fmt, &x) {
602        let dt = d.at(0, 0, 0, 0);
603        let zoned = civil_to_zoned(dt, tz.as_deref())?;
604        let secs = timestamp_to_secs(&zoned.timestamp());
605        return Ok(r_posixct(secs, tz.as_deref()));
606    }
607
608    Err(DateTimeError::AmbiguousFormat.into())
609}
610
611/// Format a POSIXct date-time as a character string.
612///
613/// @param x a POSIXct object
614/// @param format strftime-style format string (default "%Y-%m-%d %H:%M:%S")
615/// @return character string representation
616#[builtin(min_args = 1)]
617fn builtin_strftime(args: &[RValue], named: &[(String, RValue)]) -> Result<RValue, RError> {
618    // strftime is essentially format.POSIXct
619    let format = named
620        .iter()
621        .find(|(k, _)| k == "format")
622        .map(|(_, v)| v)
623        .or(args.get(1))
624        .and_then(|v| v.as_vector())
625        .and_then(|v| v.as_character_scalar())
626        .unwrap_or_else(|| "%Y-%m-%d %H:%M:%S".to_string());
627
628    let named_with_format: Vec<(String, RValue)> = vec![(
629        "format".to_string(),
630        RValue::vec(Vector::Character(vec![Some(format)].into())),
631    )];
632    builtin_format_posixct(args, &named_with_format)
633}
634
635// endregion
636
637// region: difftime
638
639/// Compute the time difference between two date-time values.
640///
641/// @param time1 first POSIXct or Date value
642/// @param time2 second POSIXct or Date value
643/// @param units time unit for the result: "secs", "mins", "hours", "days", or "weeks"
644/// @return a difftime object (numeric with class and units attributes)
645#[builtin(min_args = 2)]
646fn builtin_difftime(args: &[RValue], named: &[(String, RValue)]) -> Result<RValue, RError> {
647    let t1 = args[0]
648        .as_vector()
649        .and_then(|v| v.as_double_scalar())
650        .ok_or_else(|| {
651            RError::new(
652                RErrorKind::Type,
653                format!("expected numeric time, got {}", args[0].type_name()),
654            )
655        })?;
656    let t2 = args[1]
657        .as_vector()
658        .and_then(|v| v.as_double_scalar())
659        .ok_or_else(|| {
660            RError::new(
661                RErrorKind::Type,
662                format!("expected numeric time, got {}", args[1].type_name()),
663            )
664        })?;
665
666    let units = named
667        .iter()
668        .find(|(k, _)| k == "units")
669        .map(|(_, v)| v)
670        .or(args.get(2))
671        .and_then(|v| v.as_vector())
672        .and_then(|v| v.as_character_scalar())
673        .unwrap_or_else(|| "secs".to_string());
674
675    let diff_secs = t1 - t2;
676    let value = match units.as_str() {
677        "secs" => diff_secs,
678        "mins" => diff_secs / 60.0,
679        "hours" => diff_secs / 3600.0,
680        "days" => diff_secs / 86400.0,
681        "weeks" => diff_secs / 604800.0,
682        _ => {
683            return Err(RError::other(format!(
684                "invalid 'units' argument: '{units}'"
685            )))
686        }
687    };
688
689    let mut rv = RVector::from(Vector::Double(vec![Some(value)].into()));
690    rv.set_attr(
691        "class".to_string(),
692        RValue::vec(Vector::Character(vec![Some("difftime".to_string())].into())),
693    );
694    rv.set_attr(
695        "units".to_string(),
696        RValue::vec(Vector::Character(vec![Some(units)].into())),
697    );
698    Ok(rv.into())
699}
700
701/// Print a difftime object to stdout.
702///
703/// Displays the numeric value with units, e.g. `Time difference of 3.5 secs`.
704///
705/// @param x a difftime object
706/// @return x, invisibly
707#[interpreter_builtin(name = "print.difftime", min_args = 1)]
708fn interp_print_difftime(
709    args: &[RValue],
710    _named: &[(String, RValue)],
711    context: &BuiltinContext,
712) -> Result<RValue, RError> {
713    let val = args
714        .first()
715        .ok_or_else(|| RError::new(RErrorKind::Argument, "argument is missing".to_string()))?;
716    let rv = match val {
717        RValue::Vector(rv) => rv,
718        other => {
719            return Err(RError::new(
720                RErrorKind::Type,
721                format!("expected difftime vector, got {}", other.type_name()),
722            ))
723        }
724    };
725    let num = rv.inner.as_double_scalar().unwrap_or(0.0);
726    let units = rv
727        .get_attr("units")
728        .and_then(|v| v.as_vector()?.as_character_scalar())
729        .unwrap_or_else(|| "secs".to_string());
730    context.write(&format!("Time difference of {} {}\n", num, units));
731    context.interpreter().set_invisible();
732    Ok(val.clone())
733}
734
735// endregion
736
737// region: date component extractors
738
739/// Extract the day-of-week name from a Date object.
740///
741/// @param x a Date object
742/// @return character vector of weekday names (e.g. "Monday", "Tuesday")
743#[builtin(min_args = 1)]
744fn builtin_weekdays(args: &[RValue], _named: &[(String, RValue)]) -> Result<RValue, RError> {
745    extract_date_component(&args[0], |date| {
746        let name = match date.weekday() {
747            jiff::civil::Weekday::Monday => "Monday",
748            jiff::civil::Weekday::Tuesday => "Tuesday",
749            jiff::civil::Weekday::Wednesday => "Wednesday",
750            jiff::civil::Weekday::Thursday => "Thursday",
751            jiff::civil::Weekday::Friday => "Friday",
752            jiff::civil::Weekday::Saturday => "Saturday",
753            jiff::civil::Weekday::Sunday => "Sunday",
754        };
755        Some(name.to_string())
756    })
757}
758
759/// Extract the month name from a Date object.
760///
761/// @param x a Date object
762/// @return character vector of month names (e.g. "January", "February")
763#[builtin(min_args = 1)]
764fn builtin_months(args: &[RValue], _named: &[(String, RValue)]) -> Result<RValue, RError> {
765    extract_date_component(&args[0], |date| {
766        let name = match date.month() {
767            1 => "January",
768            2 => "February",
769            3 => "March",
770            4 => "April",
771            5 => "May",
772            6 => "June",
773            7 => "July",
774            8 => "August",
775            9 => "September",
776            10 => "October",
777            11 => "November",
778            12 => "December",
779            _ => unreachable!(),
780        };
781        Some(name.to_string())
782    })
783}
784
785/// Extract the quarter from a Date object.
786///
787/// @param x a Date object
788/// @return character vector of quarter labels (e.g. "Q1", "Q2", "Q3", "Q4")
789#[builtin(min_args = 1)]
790fn builtin_quarters(args: &[RValue], _named: &[(String, RValue)]) -> Result<RValue, RError> {
791    extract_date_component(&args[0], |date| {
792        let q = (date.month() - 1) / 3 + 1;
793        Some(format!("Q{q}"))
794    })
795}
796
797/// Helper: extract a string component from each date in a Date vector.
798fn extract_date_component(
799    x: &RValue,
800    f: impl Fn(Date) -> Option<String>,
801) -> Result<RValue, RError> {
802    match x {
803        RValue::Vector(rv) => match &rv.inner {
804            Vector::Double(vals) => {
805                let result: Vec<Option<String>> = vals
806                    .iter_opt()
807                    .map(|opt_d| opt_d.and_then(|d| days_to_date(d).and_then(&f)))
808                    .collect();
809                Ok(RValue::vec(Vector::Character(result.into())))
810            }
811            _ => Err(RError::new(
812                RErrorKind::Type,
813                format!("expected Date (numeric), got {}", rv.inner.type_name()),
814            )),
815        },
816        _ => Err(RError::new(
817            RErrorKind::Type,
818            format!("expected Date, got {}", x.type_name()),
819        )),
820    }
821}
822
823// endregion
824
825// region: as.POSIXlt (simplified: returns named list)
826
827/// Convert a value to a POSIXlt (broken-down time) list representation.
828///
829/// @param x character string, numeric, or POSIXct to convert
830/// @param tz timezone name (default: system timezone)
831/// @return a POSIXlt list with components sec, min, hour, mday, mon, year, wday, yday, isdst
832#[builtin(name = "as.POSIXlt", min_args = 1)]
833fn builtin_as_posixlt(args: &[RValue], named: &[(String, RValue)]) -> Result<RValue, RError> {
834    let x = &args[0];
835    let tz = named
836        .iter()
837        .find(|(k, _)| k == "tz")
838        .map(|(_, v)| v)
839        .and_then(|v| v.as_vector())
840        .and_then(|v| v.as_character_scalar());
841
842    // First convert to POSIXct seconds
843    let secs = match x {
844        RValue::Vector(rv) => {
845            if let Some(d) = rv.inner.as_double_scalar() {
846                d
847            } else if let Some(s) = rv.inner.as_character_scalar() {
848                parse_datetime_string(&s, tz.as_deref())?
849            } else {
850                return Err(RError::new(
851                    RErrorKind::Type,
852                    format!(
853                        "expected character or numeric, got {}",
854                        rv.inner.type_name()
855                    ),
856                ));
857            }
858        }
859        _ => {
860            return Err(RError::new(
861                RErrorKind::Type,
862                format!("expected character or numeric, got {}", x.type_name()),
863            ));
864        }
865    };
866
867    let tz_obj = if let Some(ref tz_name) = tz {
868        jiff::tz::TimeZone::get(tz_name).unwrap_or(jiff::tz::TimeZone::UTC)
869    } else {
870        jiff::tz::TimeZone::system()
871    };
872
873    let ts = secs_to_timestamp(secs).ok_or_else(|| DateTimeError::InvalidFormat {
874        reason: "timestamp out of range".to_string(),
875    })?;
876    let zoned = ts.to_zoned(tz_obj);
877    let dt = zoned.datetime();
878
879    // R's POSIXlt is a named list: sec, min, hour, mday, mon (0-based), year (since 1900),
880    // wday (0=Sunday), yday (0-based), isdst
881    let wday = match dt.date().weekday() {
882        jiff::civil::Weekday::Sunday => 0i64,
883        jiff::civil::Weekday::Monday => 1,
884        jiff::civil::Weekday::Tuesday => 2,
885        jiff::civil::Weekday::Wednesday => 3,
886        jiff::civil::Weekday::Thursday => 4,
887        jiff::civil::Weekday::Friday => 5,
888        jiff::civil::Weekday::Saturday => 6,
889    };
890
891    let yday = i64::from(dt.date().day_of_year()) - 1; // 0-based in R
892
893    let components: Vec<(Option<String>, RValue)> = vec![
894        (
895            Some("sec".to_string()),
896            RValue::vec(Vector::Double(
897                vec![Some(
898                    f64::from(dt.time().second()) + f64::from(dt.time().subsec_nanosecond()) / 1e9,
899                )]
900                .into(),
901            )),
902        ),
903        (
904            Some("min".to_string()),
905            RValue::vec(Vector::Integer(
906                vec![Some(i64::from(dt.time().minute()))].into(),
907            )),
908        ),
909        (
910            Some("hour".to_string()),
911            RValue::vec(Vector::Integer(
912                vec![Some(i64::from(dt.time().hour()))].into(),
913            )),
914        ),
915        (
916            Some("mday".to_string()),
917            RValue::vec(Vector::Integer(
918                vec![Some(i64::from(dt.date().day()))].into(),
919            )),
920        ),
921        (
922            Some("mon".to_string()),
923            RValue::vec(Vector::Integer(
924                vec![Some(i64::from(dt.date().month()) - 1)].into(),
925            )),
926        ),
927        (
928            Some("year".to_string()),
929            RValue::vec(Vector::Integer(
930                vec![Some(i64::from(dt.date().year()) - 1900)].into(),
931            )),
932        ),
933        (
934            Some("wday".to_string()),
935            RValue::vec(Vector::Integer(vec![Some(wday)].into())),
936        ),
937        (
938            Some("yday".to_string()),
939            RValue::vec(Vector::Integer(vec![Some(yday)].into())),
940        ),
941        (
942            Some("isdst".to_string()),
943            RValue::vec(Vector::Integer(vec![Some(-1i64)].into())), // -1 = unknown
944        ),
945    ];
946
947    let mut list = RList::new(components);
948    list.set_attr(
949        "class".to_string(),
950        RValue::vec(Vector::Character(
951            vec![Some("POSIXlt".to_string()), Some("POSIXt".to_string())].into(),
952        )),
953    );
954    Ok(list.into())
955}
956
957// endregion