tzcraft 0.1.2

A schema-driven date & time library: one 128-bit nanosecond timeline, a const civil calendar, and codec-aware wire formats on nextjson / rustbinary.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
# Tzcraft


[![CI](https://github.com/blueokanna/Tzcraft/actions/workflows/ci.yml/badge.svg)](https://github.com/blueokanna/Tzcraft/actions/workflows/ci.yml)
[![docs.rs](https://img.shields.io/docsrs/tzcraft)](https://docs.rs/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:

- `chrono` bakes the timezone into the type parameter; `DateTime<Tz>` drags
  generics everywhere and every operation is implemented several times.
- `time` stores an offset plus `i64` nanoseconds since midnight, squeezing
  the range and making overflow a thing to think about.
- `jiff` is 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:

```rust
use tzcraft::{Date, Weekday};

const NEW_YEAR_2025: Date = Date::from_days_since_epoch(20_089);
const WD: Weekday = NEW_YEAR_2025.weekday(); // the compiler says: Wednesday
assert_eq!(WD, Weekday::Wednesday);
```

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:

```rust
use tzcraft::{Date, Ticks};

let mut buf = [0u8; 64];
let d = Date::from_ymd(2024, 2, 29).unwrap();
let n = d.write_iso(&mut buf).unwrap();
assert_eq!(&buf[..n], b"2024-02-29");

let n = Ticks::EPOCH
    .write_rfc3339(&mut buf, tzcraft::FractionDigits::None)
    .unwrap();
assert_eq!(&buf[..n], b"1970-01-01T00:00:00Z");
```

The allocator-free sinks are exposed as `tzcraft::write::{Write, Buf}`,
ready for embedded targets.

## Quick start


```rust
use tzcraft::{Date, Duration, Months, Offset, Ticks, Weekday, Zone, Zoned};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // One timeline, any reading.
    let launch = Ticks::from_rfc3339("2024-06-15T08:30:00Z")?;
    let local = launch.to_zoned(Zone::fixed(Offset::from_hms(8, 0, 0)?));
    assert_eq!(local.to_rfc3339(tzcraft::FractionDigits::None), "2024-06-15T16:30:00+08:00");
    assert_eq!(local.date()?.weekday(), Weekday::Saturday);

    // Calendar-aware months clamp instead of overflowing.
    let jan = Date::from_ymd(2023, 1, 31)?;
    assert_eq!(jan.checked_add_months(Months::new(1))?, Date::from_ymd(2023, 2, 28)?);
    assert_eq!(jan.checked_add_months(Months::new(13))?, Date::from_ymd(2024, 2, 29)?); // leap year

    // Durations are signed and round-trip ISO 8601 exactly.
    let span = Duration::from_iso8601("P1DT2H3M4.5S")?;
    assert_eq!(span.to_iso8601(), "P1DT2H3M4.5S");

    // Text and binary share one implementation.
    let json = nextjson::nextencode(&local)?;
    let back: Zoned = nextjson::nextdecode(&json)?;
    assert_eq!(back, local);

    let bin = tzcraft::binary::encode(&local)?;
    let back: Zoned = tzcraft::binary::decode(&bin)?;
    assert_eq!(back, local);
    Ok(())
}
```

A fixed timezone is one line of `const`:

```rust
use tzcraft::{Offset, Zone};

const TOKYO: Zone = Zone::fixed(Offset::east(9 * 3600));
```

Mixing into a derived struct is natural (the derive macros come from
`nextjson`):

```rust
#[derive(Debug, PartialEq, nextjson::NsonSerialize, nextjson::NsonDeserialize)]

struct Alarm {
    name: String,
    when: Zoned,
    repeat: tzcraft::Weekday,
    snooze: tzcraft::Duration,
}
```

## 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:

- `Ticks` is `i128` nanoseconds since the epoch — storage range ≈ ±5.4×10^21
  years (the `i64`-second accessors bound the usable range to ≈ ±292 billion
  years);
- every `timestamp*` accessor and `from_timestamp*` constructor uses `i64`
  seconds — range ≈ ±292 billion years;
- `Date` is `i32` **days** since the epoch (≈ ±5.8 million years), never
  seconds;
- the only `i32` time-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:

1. **`format()` returns `Result`** — unknown directives are errors instead
   of being silently dropped.
2. **`timestamp()` floors** — a pre-epoch instant like
   `1969-12-31T23:59:59.5Z` maps to `-1`, which is what Unix time means;
   chrono truncates toward zero and would give `0`.
3. **`num_*` returns `Result<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`](https://docs.rs/tzcraft/latest/tzcraft/migration/index.html)
module documentation) show the mechanical, name-identical port.

- `chrono`: see the table above — `DateTime`/`NaiveDate`/`NaiveTime`/
  `NaiveDateTime`/`Duration`/`FixedOffset` map to `Ticks`/`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.** `Zone` is `Utc` or a fixed offset. If a wall
  clock must follow real transitions, resolve the offset yourself and hand
  the resulting `Zone::Fixed` to the library. The seam is intentionally
  narrow, and leaves room for a future `Zone::Database` variant.
- **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) and `rustbinary`
  (binary), both optional. No third-party date/time library appears anywhere
  in the dependency graph.

## Layout


```text
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.

## Dependencies & MSRV


Two optional dependencies, both exact-pinned, both wired `no_std` by
default:

```toml
nextjson = { version = "=0.1.4", default-features = false, features = ["derive"], optional = true }
rustbinary = { version = "=0.1.7", default-features = false, features = ["alloc"], optional = true }
```

`rustbinary` is pinned at **0.1.7**, the current release. The `=` is not
decoration: a caret range would let a future release silently raise the MSRV
or change wire behaviour. Only the `alloc` core is enabled, so the modules
behind its optional features (`compact`, `adaptive`, `bit-packing`,
`projection`, `archive`) — which use `usize::is_multiple_of`, stabilized in
Rust 1.87 — are never compiled, and the declared MSRV **1.81** holds. The
0.1.6 → 0.1.7 rework is additive: the type-tag table and the marker-varint /
ZigZag encoding under `options()` are unchanged, so existing payloads decode
without migration.

The binary facade (`tzcraft::binary`) exposes the bounded profile:
`encode` / `decode` inherit the standard defaults (64 MiB byte limit,
1,000,000 element limit, strict trailing bytes), and `decode_bounded`
tightens both limits explicitly for trust boundaries. Failures carry a
stable `ErrorCategory` (`UserInput` / `Protocol` / `Configuration` /
`InternalBug`) without matching the evolving error enum.

The crate's MSRV is `rust-version = "1.81"`; CI runs the full suite at
1.81.0.

## 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.
- `chrono` parity: `tests/chrono_parity.rs` exercises the migration surface
  using real chrono idioms.
- Y2038: `tests/y2038.rs` pins the boundary, pre-epoch extremes and expanded
  years.
- No-alloc: `tests/no_alloc.rs` runs only with `--no-default-features` and
  proves parsing, `Display`/`FromStr` and the `write_*` buffer APIs work
  without an allocator.
- Robustness: `tests/robustness.rs` feeds 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 `as` narrowing cast and `i64`-wide multiply was
  reviewed by hand. The audit found and fixed overflow in
  `Duration::from_days/minutes/hours/weeks` (an `i64` multiply that panicked
  in debug and wrapped in release at `i64::MAX`), a narrowing wrap in
  `Ticks`/`Zoned::checked_add_days(Days)` for `u64` day counts past `i64`,
  unchecked `i128` additions in `duration_since` and `checked_add`, an
  `as i64` wrap when formatting `%s` for extreme instants, and an `as u128`
  wrap in the extreme-instant fallbacks of `write_rfc3339` / `write_rfc2822`
  (and their `Zoned` twins) that turned negative instants into huge
  positives on the wire. Regression tests lock each one down.
- Dependencies: `cargo audit` reports zero known vulnerabilities.

```sh
cargo test --all-features
cargo clippy --all-features --all-targets -- -D warnings
cargo audit
```

## Benchmarking


[`benchmark.md`](./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`](./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](./README_CN.md)。