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.
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 FixedOffset
s 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());
// ...
}
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.