Eight steps through the library. Parsing can fail, so every program below is declared raise; a real program catches Refused at the edge. The output under each block is what the program actually prints.
01 Install and first look
Add the module, then hand a string to @moondate.parse. It reads whichever of the four shapes the text is written in and gives that shape back.
first.mbt
fn main raise {
let stamp = @moondate.parse("2026-09-22T14:30:00+08:00")
println(stamp)
}
output2026-09-22T14:30:00+08:00
A timestamp written in the text comes back written the same way. That is the whole promise of the parser: nothing is invented, and nothing that matters is lost.
When parsing fails it raises Refused, whose message says which field and which range. A date the calendar does not have is refused rather than rolled over.
refused.mbt
fn main {
try {
let _ = @moondate.parse("2026-02-29")
println("accepted")
} catch {
e => println(e.to_string())
}
}
outputday is 29, outside 1..28
02 The four shapes
A moment is a Zoned instant, a Plain wall clock, a Day, or a Clock. The text says which: an offset makes it an instant, a date and time without one is a wall clock, a bare date is a day, and a bare time is a clock.
shapes.mbt
fn main raise {
println(@moondate.parse("2026-09-22T14:30:00Z")) // an instant
println(@moondate.parse("2026-09-22T14:30:00")) // a wall clock
println(@moondate.parse("2026-09-22")) // a date
println(@moondate.parse("14:30:00")) // a time
}
Only Zoned names an instant. The other three are deliberately incomplete: turning a wall clock into an instant needs a zone nobody has supplied, so the library will not guess one.
03 Five ways to write a moment
The same instant can be written five ways, and moondate speaks all of them in both directions. Whatever it writes, strptime, parse_layout or parse_pattern reads back to the same moment.
Style
Write
Read
From
% directives
strftime
strptime
C23, POSIX, glibc, Python, chrono, C++20
Go layout
layout
parse_layout
Go's time package
LDML pattern
pattern
parse_pattern
Unicode UTS #35, ICU, Intl
ECMAScript ISO
isoformat · to_iso_string
parse
ECMA-262, Temporal
The first style is the % directives every Unix has.
write.mbt
fn main raise {
let m = @moondate.parse("2026-09-22T14:30:05.123456+08:00")
println(m.strftime("%Y-%m-%d"))
println(m.strftime("%a %A %b %B"))
println(m.strftime("%H:%M:%S.%f %z"))
println(m.strftime("%G-W%V-%u"))
let back = @moondate.strptime("22/09/2026", "%d/%m/%Y")
println(back)
}
output2026-09-22
Tue Tuesday Sep September
14:30:05.123456 +0800
2026-W39-2
2026-09-22
The second is Go's reference-time layout, where the template is Go's own reference moment and each recognisable piece names a field.
layout.mbt
fn main raise {
let m = @moondate.parse("2026-09-22T14:30:05.123456+08:00")
println(m.layout(@moondate.RFC3339))
println(m.layout(@moondate.RFC1123))
println(m.layout("Monday, 02-Jan-06 15:04:05 MST"))
}
The third is the Unicode LDML pattern: a run of a letter names a field, its length is the width, and text in single quotes is literal.
pattern.mbt
fn main raise {
let m = @moondate.parse("2026-09-22T14:30:05.123456+08:00")
println(m.pattern("EEEE, MMMM d, yyyy 'at' HH:mm"))
println(m.pattern("yyyy-MM-dd'T'HH:mm:ssXXX"))
}
outputTuesday, September 22, 2026 at 14:30
2026-09-22T14:30:05+08:00
The fourth is the ECMAScript shapes: to_iso_string is always the UTC instant with milliseconds, to_utc_string is the HTTP-style GMT form, and isoformat picks its own precision and either writes the offset or leaves it off.
ecma.mbt
fn main raise {
let m = @moondate.parse("2026-09-22T14:30:05.123456+08:00")
println(m.to_iso_string())
println(m.to_utc_string())
println(m.isoformat(fraction=3, offset=false))
}
The % set is the union of the six lineages, with the conflicts settled in the table: %f is Python's six digits (chrono's nine are %9f or %N), %Z writes UTC or UTC±HH:MM for a fixed offset, and %+ is chrono's form. The vectors come from each language's own implementation and are committed under src/; CI only replays them.
04 Calendar arithmetic
A Date knows its weekday, its day of the year, its ISO week date, and how many days it is from the epoch. Adding days carries cleanly; adding months lands on a day the month actually has.
calendar.mbt
fn main raise {
let d = @moondate.Date::new(2026, 9, 22)
println(d.weekday()) // Sunday is 0
println(d.yearday())
println(d.isocalendar())
println(d.plus_days(10))
println(d.plus_months(1))
}
output2
265
(2026, 39, 2)
2026-10-02
2026-10-22
The last day of January plus one month has nowhere to land on a short February, so it clamps to the end of the month it reaches:
An instant has seconds since 1970, and can be read in another zone without changing which moment it is. A wall clock answers None to both questions, because it is not an instant until someone says where it is.
instants.mbt
fn main raise {
let m = @moondate.parse("2026-09-22T14:30:00Z")
println(m.epoch())
match m.astimezone(@moondate.Offset(480)) {
Some(east) => println(east)
None => println("not an instant")
}
println(@moondate.Moment::of_epoch(1790087400L))
println(@moondate.parse("2026-09-22T14:30:00").epoch())
}
There is no now(): reading the clock is I/O, and this package has none. A caller that wants the current moment passes its own clock's reading to Moment::of_epoch.
06 Durations
A Span is a length of time, to the nanosecond, with no calendar attached. Add one to a moment and it carries across midnight; add one to a bare time and it wraps within the day.
spans.mbt
fn main raise {
let s = @moondate.Span::new(days=1L, hours=2L, minutes=3L)
println(s.parts())
println(@moondate.Span::new(millis=1500L).total_seconds())
let m = @moondate.parse("2026-09-22T23:30:00")
println(m.plus(@moondate.hour))
println(m.plus(@moondate.Span::new(hours=-24L)))
println(@moondate.parse("23:30:00").plus(@moondate.hour))
}
A span is not a number of months. A month is not a fixed length, so adding one is a calendar operation (Date::plus_months) rather than arithmetic.
07 HTTP and ASN.1
The same instant has a different spelling on an HTTP header and inside a certificate. Both readers apply the two-digit-year window their own specification names — and those windows differ.
wire.mbt
fn main raise {
let m = @moondate.http("Sun, 06 Nov 1994 08:49:37 GMT")
println(m)
println(m.http_text())
println(@moondate.asn1("491231235959Z"))
}
output1994-11-06T08:49:37Z
Sun, 06 Nov 1994 08:49:37 GMT
2049-12-31T23:59:59Z
HTTP reads IMF-fixdate and the two obsolete forms a recipient must still accept, but writes only IMF-fixdate. ASN.1's UTCTime puts 49 and under in the 2000s; HTTP's rule puts 69 and under there instead.
08 Schedules with cron
@cron.parse takes five fields as crontab(5) defines them, or six with seconds in front. next answers with the first moment strictly after a given one, or None when the expression names no day that exists.
schedule.mbt
fn main raise {
let now = @moondate.parse("2026-09-22T14:30:00Z")
println(@cron.parse("0 9 * * MON-FRI").next(now))
println(@cron.parse("@daily").next(now))
println(@cron.parse("0 0 30 2 *").next(now))
}
When both day fields are restricted they are or-ed, which is Vixie cron's rule: the thirteenth or any Friday, not Friday the thirteenth.
or-rule.mbt
fn main raise {
let both = @cron.parse("0 0 13 * 5")
println(both.matches(@moondate.parse("2026-09-25T00:00:00Z"))) // a Friday
println(both.matches(@moondate.parse("2026-10-13T00:00:00Z"))) // the thirteenth
let day_only = @cron.parse("0 0 13 * *")
println(day_only.matches(@moondate.parse("2026-09-25T00:00:00Z")))
}
outputtrue
true
false
That is the whole library in a pass. The API reference lists every type and method, and the playground runs the same code in the browser.