Search code examples
rustrust-chrono

How do I create a generic Rust struct with a chrono time zone?


Disclaimer: I am new to Rust (previous experience is Python, TypeScript, and Go, in that order), and it is entirely possible that I am missing something really obvious.

I am trying to build a Rust clock interface. My basic goal here is that I have a small clock struct that reports the actual time, and a stub version that will report a faked version for testing. Note that these are historical tests rather than unit tests: my goal is to replay historical data. I think part of the problem may also be that I do not understand chrono well enough. It is clearly a great library, but I am having trouble with the type vs. instance relationships in chrono and chrono_tz.

Anyway, this is what I have:

use chrono::{DateTime, TimeZone, Utc};

/// A trait representing the internal clock for timekeeping requirements.
/// Note that for some testing environments, clocks may be stubs.
pub trait Clock<Tz: TimeZone> {
  fn now() -> DateTime<Tz>;
}

My ultimate goal is to have other structs have a dyn Clock in a particular time zone. That clock may be the system clock (with appropriate time zone transformation) or it might be a stub of some kind.

This is my attempt at a system clock shim, and where everything is going horribly wrong:

/// A clock that reliably reports system time in the requested time zone.
struct SystemClock<Tz: TimeZone> {
  time_zone: std::marker::PhantomData<*const Tz>,
}
impl<Tz: TimeZone> Clock<Tz> for SystemClock<Tz> {
  /// Return the current time.
  fn now() -> DateTime<Tz> {
    Utc::now().with_timezone(&Tz)
  }
}

The key problem is Utc::now().with_timezone(&Tz). The compiler wants a value, not a type. Fair enough, except chrono and chrono_tz do not seem to have time zone values. I have been looking all over for the right thing to put here and nothing seems to be the right answer.


Solution

  • The problem is that the time zone type is not sufficient to implement now() as specified. Most time zones aren't implemented as separate types, Utc is actually special in that regard (as is Local). Normal time zones are implemented as values of more general time zone types, such as FixedOffset or chrono_tz::Tz. Those types store the time zone offset at run-time, so valid FixedOffsets include FixedOffset::east(1) (CET), FixedOffset::west(5) (EST), or even FixedOffset::east(0) (GMT, UTC). This is why DateTime::with_timezone() requires a concrete time zone value, not just its type.

    The simplest fix to accommodate this is to modify now() to accept a time zone value:

    pub trait Clock<Tz: TimeZone> {
        fn now(tz: Tz) -> DateTime<Tz>;
    }
    
    struct SystemClock<Tz: TimeZone> {
        time_zone: std::marker::PhantomData<*const Tz>,
    }
    
    impl<Tz: TimeZone> Clock<Tz> for SystemClock<Tz> {
        fn now(tz: Tz) -> DateTime<Tz> {
            Utc::now().with_timezone(&tz)
        }
    }
    

    Usage would look like this:

    fn main() {
        // now in Utc
        println!("{:?}", SystemClock::now(Utc));
        // now in GMT+1
        println!("{:?}", SystemClock::now(FixedOffset::east(1)));
        // now in Copenhagen time
        println!(
            "{:?}",
            SystemClock::now("Europe/Copenhagen".parse::<chrono_tz::Tz>().unwrap())
        );
    }
    

    Note in particular the second and the last example, where the time zones are chosen at run-time, and are clearly not captured by the time zone type.

    If you find it redundant to specify the time zone value in trait methods such as now(), you could give the methods access to self and keep the time zone value in a field in SystemClock (which would also nicely eliminate the PhantomData):

    pub trait Clock<Tz: TimeZone> {
        fn now(&self) -> DateTime<Tz>;
    }
    
    struct SystemClock<Tz: TimeZone> {
        time_zone: Tz,
    }
    
    impl SystemClock<Utc> {
        fn new_utc() -> SystemClock<Utc> {
            SystemClock { time_zone: Utc }
        }
    }
    
    impl<Tz: TimeZone> SystemClock<Tz> {
        fn new_with_time_zone(tz: Tz) -> SystemClock<Tz> {
            SystemClock { time_zone: tz }
        }
    }
    
    impl<Tz: TimeZone> Clock<Tz> for SystemClock<Tz> {
        fn now(&self) -> DateTime<Tz> {
            Utc::now().with_timezone(&self.time_zone)
        }
    }
    
    fn main() {
        println!("{:?}", SystemClock::new_utc().now());
        println!("{:?}", SystemClock::new_with_time_zone(FixedOffset::east(1)).now());
        // ...
    }
    

    Playground

    For time zones where the offset is known at compile-time, such as Utc and Local, the time zone field will take up no space, and SystemClock will be zero-sized, like in your original design. For time zones where the offset is chosen at run-time, SystemClock will store that information in the struct.

    Finally, with the advent of const generics, one could imagine a variant of FixedOffset that stores the offset at compile time as const generic. Such types are offered by the chrono-simpletz crate, which you can use to create the kind of Clock trait you wanted initially. Since its types are fully specified at compile-time, they implement Default, so you can trivially obtain the time zone value with Tz::default(). The result (sadly again requiring PhantomData) might look like this:

    use std::marker::PhantomData;
    
    use chrono::{DateTime, TimeZone, Utc};
    use chrono_simpletz::{UtcZst, known_timezones::UtcP1};
    
    type UtcP0 = UtcZst<0, 0>;  // chrono_simpletz doesn't provide this
    
    pub trait Clock<Tz: TimeZone + Default> {
        fn now() -> DateTime<Tz>;
    }
    
    struct SystemClock<Tz: TimeZone> {
        time_zone: PhantomData<fn() -> Tz>,
    }
    
    impl<Tz: TimeZone + Default> Clock<Tz> for SystemClock<Tz> {
        fn now() -> DateTime<Tz> {
            Utc::now().with_timezone(&Tz::default())
        }
    }
    
    fn main() {
        println!("{:?}", SystemClock::<UtcP0>::now());
        println!("{:?}", SystemClock::<UtcP1>::now());
    }
    

    In case it's not obvious which option to choose for production, I recommend the second one, the one with the playground link.