Tzcraft
A date and time library for Rust built on one idea: one timeline, and
everything else is a projection. The whole axis is a signed 128-bit
nanosecond counter (Ticks); the proleptic Gregorian calendar, weekdays,
ISO weeks and timezone offsets are pure projections onto that axis. No web
of Add impls, no global "current timezone" variable, no IANA database
downloads at runtime, no unsafe.
The crate is #![no_std] in every configuration and, with the default
features off, builds without an allocator at all.
Why another time library
The existing reference libraries carry shapes that this project deliberately does not want:
chronobakes the timezone into the type parameter;DateTime<Tz>drags generics everywhere and every operation is implemented several times.timestores an offset plusi64nanoseconds since midnight, squeezing the range and making overflow a thing to think about.jiffis pleasant, but behind it sit the IANA database, runtime state and a heavy dependency tree.
tzcraft swaps in a different set of assumptions. Each one is a fact about
the code you can check yourself.
1. One timeline, one source of arithmetic.
Ticks is the only instant type: a signed 128-bit nanosecond count since
the Unix epoch (1970-01-01T00:00:00Z). 128 bits buys full nanosecond
precision and a storage range of roughly ±5.4×10^21 years — no "small
instant / big instant" split, no overflow-collapse strategy to memorize. Duration is
a separate signed span type: the type system refuses to let you add two
instants, because Ticks + Ticks does not compile.
2. Civil types are projections, not owners.
Date, TimeOfDay and CivilDateTime carry no arithmetic of their own.
Midnight-crossing additions and year-boundary carries project onto the
single i128 nanosecond axis and are computed once. Calendar-aware
operations (months, years) exist in exactly one place — project, adjust,
re-project — so you never hunt for the right impl among a dozen type
combinations.
3. The compiler is the calendar.
Leap rules, the day-count ↔ civil inversion, weekdays, ISO weeks — all
const fn. The compiler folds calendar math at compile time:
use ;
const NEW_YEAR_2025: Date = from_days_since_epoch;
const WD: Weekday = NEW_YEAR_2025.weekday; // the compiler says: Wednesday
assert_eq!;
Timezones are const data too: a Zone is either Utc or a fixed
Offset, carried inline with the instant in Zoned. No global registry, no
mutable "current zone", no hidden context. Zoned is therefore Copy +
Send + Sync for free.
4. The codec picks the wire shape.
Every type implements nextjson's format-neutral contract
(NsonSerialize / NsonDeserialize) exactly once. At encode time it asks
is_human_readable():
| Type | Human-readable (nextjson JSON) | Binary (rustbinary) |
|---|---|---|
Ticks |
RFC 3339 string | i128 nanoseconds |
Duration |
ISO 8601 duration string | i128 nanoseconds |
Date |
YYYY-MM-DD |
i32 days |
TimeOfDay |
HH:MM:SS[.f] |
u64 ns of day |
CivilDateTime |
zone-less ISO string | packed i128 |
Offset |
+08:00 / Z |
i32 seconds |
Zone |
UTC / offset string |
tagged array |
Zoned |
RFC 3339 with offset | [ticks, offset] array |
Weekday |
"Monday" (numbers also accepted) |
u8 discriminant |
Month |
"January" (numbers also accepted) |
u8 month number |
JSON stays readable and self-describing, the binary profile stays compact — no separate serde module, no feature that silently changes the format. One implementation, two shapes.
no_std, with or without an allocator
The crate is #![no_std] in every build. The alloc feature (on by
default, because std implies it) only gates the APIs that return an owned
String and the codecs:
| Feature | What it enables |
|---|---|
std (default) |
Ticks::now(), to_std_time, std::error::Error |
alloc (default via std) |
to_rfc3339, format, to_iso, to_iso8601, ... (the String-returning methods) |
serde (default) |
nextjson text/binary codec implementations (tzcraft::codec) |
binary (default) |
rustbinary compact wire (tzcraft::binary) |
With --no-default-features the crate links no allocator: parsing,
arithmetic, Display/FromStr, and every write_* buffer-formatting
method keep working. Every String-returning method has an allocator-free
twin that writes into a caller-owned slice and returns the byte count:
use ;
let mut buf = ;
let d = from_ymd.unwrap;
let n = d.write_iso.unwrap;
assert_eq!;
let n = EPOCH
.write_rfc3339
.unwrap;
assert_eq!;
The allocator-free sinks are exposed as tzcraft::write::{Write, Buf},
ready for embedded targets.
Quick start
use ;
A fixed timezone is one line of const:
use ;
const TOKYO: Zone = fixed;
Mixing into a derived struct is natural (the derive macros come from
nextjson):
Y2038: safe by construction
The Year 2038 problem is a 32-bit signed-seconds overflow
(i32::MAX seconds after the epoch = 2038-01-19T03:14:07Z). tzcraft
cannot hit it:
Ticksisi128nanoseconds since the epoch — storage range ≈ ±5.4×10^21 years (thei64-second accessors bound the usable range to ≈ ±292 billion years);- every
timestamp*accessor andfrom_timestamp*constructor usesi64seconds — range ≈ ±292 billion years; Dateisi32days since the epoch (≈ ±5.8 million years), never seconds;- the only
i32time-domain value,Offset, is bounded to ±24 h and validated at construction.
tests/y2038.rs pins the boundary behaviour: the exact rollover second
(2_147_483_647 → 2_147_483_648), pre-epoch extremes, expanded years past
9999, and text round-trips across the boundary.
Moving from chrono
tzcraft covers the everyday chrono API. _opt variants become plain
? in our Result model:
| chrono | tzcraft |
|---|---|
Utc::now() / Local::now() |
Ticks::now()? / Zoned::now_utc()? |
DateTime::<Utc> |
Ticks |
DateTime::<FixedOffset> |
Zoned |
NaiveDate |
Date |
NaiveTime |
TimeOfDay |
NaiveDateTime |
CivilDateTime |
Duration / TimeDelta |
Duration (same-named seconds/hours/days/weeks/num_*) |
from_ymd_opt / from_hms_opt |
Date::from_ymd / TimeOfDay::from_hms (return Result) |
DateTime::from_timestamp / timestamp() |
Ticks::from_timestamp / timestamp() (plus _millis/_micros/_nanos) |
date.and_hms_opt(...) |
date.and_hms(...)? |
d.checked_add_months(Months::new(1)) |
same name, same signature |
d.checked_add_days(Days::new(1)) |
same name, same signature |
dt.format("%Y-%m-%d %H:%M:%S") |
dt.format("%Y-%m-%d %H:%M:%S")? (unknown directives are errors, not silently dropped) |
NaiveDate::parse_from_str / NaiveDateTime::parse_from_str |
Date::parse_from_str(s, fmt) / CivilDateTime::parse_from_str(s, fmt) |
DateTime::parse_from_str |
Ticks::parse_from_str / Zoned::parse_from_str (the civil path needs a timezone offset) |
to_rfc3339 / parse_from_rfc3339 |
to_rfc3339(frac) / from_rfc3339 |
to_rfc2822 / parse_from_rfc2822 |
same names |
Datelike::year/month/day/ordinal/weekday/iso_week/num_days_from_ce |
same-named inherent methods |
Timelike::hour/minute/second/nanosecond/num_seconds_from_midnight |
same-named inherent methods |
FixedOffset::east_opt/from_hms_opt |
Offset::from_seconds / Offset::from_hms |
dt.with_timezone(...) |
z.with_zone(...) |
checked_add_signed / signed_duration_since |
same names |
with_year/with_month/.../with_nanosecond |
same names |
Duration::to_std/from_std |
same names |
| serde support | nextjson NsonSerialize / NsonDeserialize (text + rustbinary binary) |
Three deliberate differences. They are correctness or safety calls, not oversights:
format()returnsResult— unknown directives are errors instead of being silently dropped.timestamp()floors — a pre-epoch instant like1969-12-31T23:59:59.5Zmaps to-1, which is what Unix time means; chrono truncates toward zero and would give0.num_*returnsResult<i64>— out-of-range values are explicit errors instead of silent overflow.
Local (the system-local zone) is not in v1: pure std cannot read the
local offset (that needs libc/platform FFI, and the core crate only
depends on nextjson and rustbinary while denying unsafe). If your wall
clock must follow the real local zone, resolve the offset with a platform
API and hand it to Zone::fixed(...) — the seam is explicit.
Migration: bring chrono / time / rustix code in
Migration is one-way easy: into tzcraft. The crate does not depend on
chrono, time or rustix — its only dependencies are nextjson and
rustbinary (both optional) — so there is nothing that links those crates
and nothing that makes leaving easier than arriving. The tables above (and
the time/rustix equivalents in the
tzcraft::migration
module documentation) show the mechanical, name-identical port.
chrono: see the table above —DateTime/NaiveDate/NaiveTime/NaiveDateTime/Duration/FixedOffsetmap toTicks/Date/TimeOfDay/CivilDateTime/Duration/Offset.time:OffsetDateTime→Zoned,PrimitiveDateTime→CivilDateTime,Date→Date,Time→TimeOfDay,UtcOffset→Offset,Duration→Duration.rustix:Timespec(a POSIX{tv_sec, tv_nsec}pair) →Ticks::from_timespec(tv_sec, tv_nsec).
Porting a value is exact: every tzcraft accessor yields the same
integers the reference libraries use (Ticks::to_unix_seconds →
(i64, u32), Date::parts, TimeOfDay::parts, Offset::as_seconds,
Duration::as_nanos). The full mapping and worked examples are in the
tzcraft::migration module.
What's deliberately not here
- No IANA database, no DST.
ZoneisUtcor a fixed offset. If a wall clock must follow real transitions, resolve the offset yourself and hand the resultingZone::Fixedto the library. The seam is intentionally narrow, and leaves room for a futureZone::Databasevariant. - Only the proleptic Gregorian calendar (year 0 = 1 BCE). No Julian, no Hebrew, no other calendars.
- Strict ISO 8601 / RFC 3339 / RFC 2822 plus strftime. What we ship is complete and tested; there is no half-finished template engine.
- No
unsafe. Dependencies:nextjson(codecs) andrustbinary(binary), both optional. No third-party date/time library appears anywhere in the dependency graph.
Layout
src/
calendar.rs civil calendar core: leap rules, day<->civil, weekday, ISO week — all const fn
units.rs Days / Months / IsoWeek (chrono-compatible typed units)
ticks.rs Ticks: the only instant type, i128 nanoseconds
duration.rs Duration: signed spans
date.rs Date: i32-day projection
time.rs TimeOfDay: u64 ns of day
datetime.rs CivilDateTime: zone-less date-time
offset.rs Offset: seconds within +/-24h
zone.rs Zone: Utc / fixed offset
zoned.rs Zoned: Ticks + Zone
write.rs allocator-free Write trait + Buf sinks (no_std, no alloc)
format.rs hand-written ISO / RFC 3339 parser & formatter
strftime.rs strftime engine + RFC 2822 (the chrono-compatible surface)
codec.rs nextjson contract impls (human-readable vs binary branch)
binary.rs rustbinary facade
migration.rs guide: bringing chrono / time / rustix code in
The parsers scan bytes one at a time and every failure carries a byte offset. Fractional seconds are capped at 9 digits — anything more is rejected, never truncated. Truncation would be lying about the data.
Tests & safety
- Calendar: day-by-day round trips over ±200,000 days, the full calendar round trip over 6,000 years, weekday anchors, ISO week boundary vectors.
- Formatting: RFC 3339 / RFC 2822 / strftime round trips plus a reject-list of malformed inputs.
- Codec: every type round-trips through both nextjson text and rustbinary binary, plus derived structs that mix tzcraft types.
chronoparity:tests/chrono_parity.rsexercises the migration surface using real chrono idioms.- Y2038:
tests/y2038.rspins the boundary, pre-epoch extremes and expanded years. - No-alloc:
tests/no_alloc.rsruns only with--no-default-featuresand proves parsing,Display/FromStrand thewrite_*buffer APIs work without an allocator. - Robustness:
tests/robustness.rsfeeds thousands of adversarial inputs (random bytes, oversized inputs, malformed structures, extreme numbers, hostile format strings) through every parse and format entry point. The contract: no input may panic or allocate without bound. - Low-level audit: every
asnarrowing cast andi64-wide multiply was reviewed by hand. The audit found and fixed overflow inDuration::from_days/minutes/hours/weeks(ani64multiply that panicked in debug and wrapped in release ati64::MAX), a narrowing wrap inTicks/Zoned::checked_add_days(Days)foru64day counts pasti64, uncheckedi128additions induration_sinceandchecked_add, anas i64wrap when formatting%sfor extreme instants, and anas u128wrap in the extreme-instant fallbacks ofwrite_rfc3339/write_rfc2822(and theirZonedtwins) that turned negative instants into huge positives on the wire. Regression tests lock each one down. - Dependencies:
cargo auditreports zero known vulnerabilities.
Benchmarking
benchmark.md holds the comparison report: tzcraft vs
chrono 0.4.45 / time 0.3.55 / jiff 0.2.35, for RFC 3339 parse and
format, civil projection, date arithmetic, duration arithmetic and weekday,
plus a panic-freedom fuzz run and a dependency/unsafe footprint.
The harness lives in the benchmarks/ package (publish = false). It
is the only place the three comparison libraries appear; they are never part
of tzcraft's dependency graph. A GitHub Action re-runs it on every push to
main and commits the fresh numbers back to benchmark.md.
Methodology is documented in benchmarks/README.md
and inline in the report: identical inputs, black_box, varied input arrays
to defeat loop-invariant code motion, and a minimum-of-three timing scheme.
Numbers are machine-specific and only comparable within a single run.
CI
.github/workflows/ci.yml runs on every push and pull request: formatting,
clippy with -D warnings, debug and release tests, the feature matrix
(--no-default-features and each of alloc / std / serde / binary),
docs built with -D warnings, a docs.rs-condition build (nightly with
--cfg docsrs, the exact flags docs.rs uses), a security audit against the
RustSec advisory database, and the full test suite at the declared MSRV
1.81.
License
Apache-2.0. 中文版 README:README_CN.md。