moondate
Guide · Summer

From a string to a schedule.

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
  println(@moondate.parse("20260922T143000+0800"))   // the basic form
  println(@moondate.parse("2020-01-01[+09:00]"))     // the offset in the name
}
output2026-09-22T14:30:00Z 2026-09-22T14:30:00 2026-09-22 14:30:00 2026-09-22T14:30:00+08:00 2020-01-01T00:00:00+09:00

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.

Two spellings of the same grammar are read as well. The basic form drops the separators — 20260922T143000+0800 — and a bracketed offset stands in for the one beside it, so 2020-01-01[+09:00] is midnight nine hours ahead. A bracketed name that is a place is different: [America/New_York] alone says which zone but not which side of a transition the clock was on, so it is refused unless the offset is written beside it.

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.

StyleWriteReadFrom
% directivesstrftimestrptimeC23, POSIX, glibc, Python, chrono, C++20
Go layoutlayoutparse_layoutGo's time package
LDML patternpatternparse_patternUnicode UTS #35, ICU, Intl
ECMAScript ISOisoformat · to_iso_stringparseECMA-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"))
}
output2026-09-22T14:30:05+08:00 Tue, 22 Sep 2026 14:30:05 +08:00 Tuesday, 22-Sep-26 14:30:05 +08:00

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 rest of the pattern letters answer in the widths they name. Y and w are the week year and its week — a week starts on Monday and the first week is the one that holds 1 January — b and B name the day period, c is the standalone weekday, and a fifth letter asks for the narrow form of an era, a quarter or a weekday.

letters.mbt
fn main raise {
  let m = @moondate.parse("2026-09-22T14:30:05.123456+08:00")
  println(m.pattern("Y-'W'ww c"))
  println(m.pattern("B"))
  println(m.pattern("GGGGG QQQQQ"))
}
output2026-W39 2 in the afternoon A 3

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))
}
output2026-09-22T06:30:05.123Z Tue, 22 Sep 2026 06:30:05 GMT 2026-09-22T14:30:05.123

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.

Flags sit between the per cent sign and the letter: - drops the padding, _ pads with spaces, 0 pads with zeros, a number sets the width, and ^ and # change the case of a name. The C99 selectors E and O are kept as well: %Ec is %c, and the two ISO 8601 spellings of an offset are %Ez and %Oz.

flags.mbt
fn main raise {
  let m = @moondate.parse("2026-09-22T14:30:05.123456+08:00")
  println(m.strftime("%#B %10Y %-d"))
  println(m.strftime("%Ez %Oz"))
  println(m.strftime("%Ec"))
}
outputSEPTEMBER 0000002026 22 +08:00 +0800 Tue Sep 22 14:30:05 2026

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:

clamp.mbt
fn main raise {
  println(@moondate.Date::new(2023, 1, 31).plus_months(1))
  println(@moondate.Date::new(2024, 1, 31).plus_months(1))
  println(@moondate.Date::new(1970, 1, 1).days())      // epoch
  println(@moondate.Date::new(2026, 9, 22).ordinal())  // 0001-01-01 is 1
}
output2023-02-28 2024-02-29 0 739881

05 Instants and zones

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())
}
outputSome(1790087400) 2026-09-22T22:30:00+08:00 2026-09-22T14:30:00Z None

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 Named zones

An offset is not a zone. The same -04:00 is New York in July and nowhere in particular in January, and only a database can tell you which. It lives in tzdata, a package of its own so that a program which formats an HTTP date does not have to carry it.

named.mbt
fn main raise {
  let tz = @tzdata.load("America/New_York")
  let moment = tz.moment(1790058605L)
  println(moment)
  println(moment.strftime("%H:%M %Z %z"))
  println(moment.pattern("VV"))
  println(moment.layout(@moondate.RFC1123))
}
output2026-09-22T02:30:05-04:00 02:30 EDT -0400 America/New_York Tue, 22 Sep 2026 02:30:05 EDT

The moment carries the name and the offset in force at that instant, so it goes on being a value: it compares, formats and moves to another zone with no database in reach. What it cannot do is cross a transition on its own — Tz::moment is how it comes back for that.

A wall clock is the harder question. Twice a year one hour never happens and one happens twice, so a zone answers with which instants the clock names, and the caller says what to do about it:

fold.mbt
fn main raise {
  let tz = @tzdata.load("America/New_York")
  let spring = tz.read(
    @moondate.Date::new(2026, 3, 8),
    @moondate.Time::new(2, 30),
  )
  println(spring)
  println(spring.epoch())
  let autumn = tz.read(
    @moondate.Date::new(2026, 11, 1),
    @moondate.Time::new(1, 30),
  )
  println(autumn)
  println(autumn.epoch(fold=Later))
}
outputnever, between 1772951400 and 1772955000 1772955000 twice, at 1793511000 and 1793514600 1793514600

fold=Compatible, the default, is the answer most callers mean: the first of two repeated hours and the second of two skipped ones. Earlier and Later pick by instant, and Reject refuses rather than picking.

The same two questions in one call: @tzdata.parse reads the place from the text, asks the database what that clock means, and takes fold for the hour that has none. @moondate.parse cannot do this on its own — it has no database — so a place name alone is the one text it refuses.

text.mbt
fn main raise {
  println(@tzdata.parse("2026-11-01T01:30:00[America/New_York]"))
  println(@tzdata.parse("2026-11-01T01:30:00[America/New_York]", fold=Later))
}
output2026-11-01T01:30:00-04:00 2026-11-01T01:30:00-05:00

If the zone is already a TZif file on disk, the reader is in zone and needs no database: @zone.Tz::parse(bytes) takes the file's bytes and gives back the same Tz. A file written before 2038 still answers for 2100, because the POSIX rule in its footer is read rather than expanded.

Fetching those bytes is the one thing in the library that opens a file, and it is a package of its own for that reason: @zoneinfo. system() is the zone the machine is set to — TZ when it names one, /etc/localtime when it does not — and named and open are the other two ways to reach one.

machine.mbt
fn main raise {
  let here = @zoneinfo.system()
  println(here.name())
  println(here.moment(1790058605L).strftime("%H:%M %Z"))
  let shanghai = @zoneinfo.named("Asia/Shanghai")
  println(shanghai.moment(1790058605L).strftime("%H:%M %Z"))
}

TZ is read the way a C library reads it: empty is UTC, a name is a file in the directory, a path is that file, and anything else is a POSIX rule, which is how a container states a zone without carrying one. A machine that names none of those is an error rather than UTC — guessing UTC is how a program silently writes timestamps eight hours out.

A moment carries a zone it can write but not cross, because a transition is a question only the rules can answer. ZonedDateTime, in zone, keeps the Tz itself, so it can: move it to another zone and the instant does not move, only the reading; add a duration and the instant moves by it, whatever a day happens to be. It is built from a wall clock with fold deciding the two hours, or from an instant it already names.

zoned.mbt
fn main raise {
  let ny = @tzdata.load("America/New_York")
  let sh = @tzdata.load("Asia/Shanghai")
  let now = @zone.ZonedDateTime::from_unix(1790058605L, zone=ny)
  println(now.isoformat())
  println(now.into(sh).isoformat())
  let spring = @zone.ZonedDateTime::of(2026, 3, 8, hour=2, minute=30, zone=ny)
  println(spring.isoformat())
  let autumn = @zone.ZonedDateTime::of(2026, 11, 1, hour=1, minute=30, zone=ny, fold=@zone.Later)
  println(autumn.isoformat())
  let step = now.plus(@moondate.Span::new(days=1))
  println(step.isoformat())
}
output2026-09-22T02:30:05-04:00[America/New_York] 2026-09-22T14:30:05+08:00[Asia/Shanghai] 2026-03-08T03:30:00-04:00[America/New_York] 2026-11-01T01:30:00-05:00[America/New_York] 2026-09-23T02:30:05-04:00[America/New_York]

Reading a wall clock back through the zone is the round trip: now.moment() gives the moment the zone writes for the instant, the offset in force beside the name, so the value and the text agree. A field edit takes an offset policy — Prefer keeps the instant the old offset named when it still is, Ignore goes straight to the rules, and Use keeps the old offset whether or not the zone still has it.

07 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))
}
output(1, 7380, 0) 1.5 2026-09-23T00:30:00 2026-09-21T23:30:00 00:30:00

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.

08 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.

09 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))
}
outputSome(2026-09-23T09:00:00Z) Some(2026-09-23T00:00:00Z) None

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.