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
}
output2026-09-22T14:30:00Z 2026-09-22T14:30:00 2026-09-22 14:30: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.

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

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

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))
}
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.