README.md

# automata

[![CI](https://github.com/nao1215/automata/actions/workflows/ci.yml/badge.svg)](https://github.com/nao1215/automata/actions/workflows/ci.yml)
[![Hex.pm](https://img.shields.io/hexpm/v/automata)](https://hex.pm/packages/automata)
[![Downloads](https://img.shields.io/hexpm/dt/automata)](https://hex.pm/packages/automata)
[![Hex Docs](https://img.shields.io/badge/hexdocs-online-purple)](https://hexdocs.pm/automata)
[![License](https://img.shields.io/hexpm/l/automata)](LICENSE)

Cron, RRULE, retries, filesystem events, and finite automata for
Gleam. Pure data: every module produces the same answers on the
BEAM and on the JavaScript target.

## Features

- `automata` — deterministic finite automaton helper.
- `automata/cron` — UNIX 5-field cron with separate parse, validate, normalise, match, iterate, and next phases.
- `automata/rrule` — RFC 5545 RRULE subset (FREQ, INTERVAL, COUNT, UNTIL, BYDAY, BYMONTH, BYMONTHDAY, BYHOUR, BYMINUTE) anchored on a `ValidDateTime`.
- `automata/schedule` — one matcher / iterator / next-after API across compiled cron, RRULE, fixed-interval, and one-shot schedules.
- `automata/event` — typed `Event(body)` values with source kinds, correlation / causation / trace metadata, and filter / match combinators.
- `automata/fsevent` — fsnotify-style Create / Write / Remove / Rename / Chmod ops derived from two filesystem snapshots, with file_id-based rename detection.
- `automata/retry` — deterministic retry policies (fixed, exponential, capped exponential) with optional jitter and a step-by-step `Decision` interface.

## Install

```sh
gleam add automata
```

## Cron

```gleam
import automata/cron
import automata/schedule/ast as schedule_ast
import gleam/io

pub fn main() {
  let assert Ok(raw) = cron.parse("*/15 9-17 * * MON-FRI")
  let assert Ok(spec) = cron.validate(raw)
  let assert Ok(after) =
    schedule_ast.try_valid_datetime(
      year: 2026,
      month: 5,
      day: 11,
      hour: 9,
      minute: 7,
      second: 0,
    )

  cron.next_after(spec, after: after) |> io.debug
}
```

Reuse a normalised plan when you evaluate the same spec many times:

```gleam
let plan = cron.normalize(spec)
cron.matches_plan(plan: plan, at: now)
cron.next_after_plan(plan: plan, after: now)
```

Builder form, without importing the validator submodule:

```gleam
import automata/cron

pub fn business_hours() {
  cron.builder()
  |> cron.with_minute(cron.every(15))
  |> cron.with_hour(cron.between(from: 9, to: 17))
  |> cron.with_day_of_week(cron.one_of([cron.item_range(from: 1, to: 5)]))
  |> cron.build
}
```

## RRULE

```gleam
import automata/rrule
import automata/rrule/validator as rule_validator
import automata/schedule/ast as schedule_ast

pub fn every_other_week() {
  let assert Ok(anchor) =
    schedule_ast.try_valid_datetime(
      year: 2026,
      month: 1,
      day: 5,
      hour: 9,
      minute: 30,
      second: 0,
    )

  let assert Ok(spec) =
    rrule.builder(rule_validator.Weekly)
    |> rrule.with_interval(2)
    |> rrule.with_by_day([
      rrule.weekday(day: schedule_ast.Monday),
      rrule.weekday(day: schedule_ast.Wednesday),
    ])
    |> rrule.with_by_hour([9])
    |> rrule.with_by_minute([30])
    |> rrule.build

  rrule.next_after(spec, anchor: anchor, after: anchor)
}
```

Anchor seconds are preserved end-to-end. `BYSECOND`, `BYYEARDAY`,
`BYWEEKNO`, `BYSETPOS`, `WKST`, `DTSTART`, `RDATE`, and `EXDATE` are
not in the supported subset. `rrule.normalize/2` returns an
`RRulePlan` for use with `rrule.matches_plan` / `iterator_after_plan`
/ `next_after_plan` when you reuse the same spec/anchor pair.

## Schedule

```gleam
import automata/cron
import automata/schedule

pub fn shared_api(now, after) {
  let assert Ok(raw) = cron.parse("*/30 9-17 * * 1-5")
  let assert Ok(spec) = cron.validate(raw)
  let compiled = schedule.from_cron(spec)

  schedule.matches(compiled, at: now)
  schedule.next_after(compiled, after: after)
}
```

`schedule.from_every(interval_seconds:, anchor:)` builds a
fixed-interval schedule; `schedule.from_once(at:)` fires exactly
once. All four constructors share the same matcher / iterator /
next-after API.

## Events

```gleam
import automata/event/builtin/body
import automata/event/builtin/filter as builtin_filter
import automata/event/filter
import automata/fsevent/ast as fs_ast
import automata/schedule/ast as schedule_ast

pub fn cron_event(now) {
  body.new(
    id: "evt-001",
    occurred_at: now,
    source_id: "daily-report",
    body: body.scheduled(
      plan_id: "daily-report",
      fired_at: now,
      schedule_kind: body.CronSchedule,
    ),
  )
}

pub fn watch_logs() {
  filter.all_of([
    builtin_filter.is_file_with_op(op: fs_ast.Write),
    builtin_filter.by_path_prefix(prefix: "/var/log/"),
    filter.negate(builtin_filter.by_path_suffix(suffix: ".tmp")),
  ])
}
```

`body.new/4` derives the canonical `SourceKind` from the body, so
`Scheduled` pairs with `ScheduleSource`, `FileSystem(_)` with
`FileSystemSource`, and `Custom("vendor.kind", _)` with
`CustomSource("vendor.kind")`. `Event.occurred_at` is a
`ValidDateTime`, which rejects impossible calendar dates such as
2026-02-30. `event.continue_from/5` chains a child event from a
parent, copying `correlation_id` and `trace_id` and setting
`causation_id` automatically.

## Filesystem events

`automata/fsevent` reproduces fsnotify-style operations as a pure
function over two `Snapshot` values. The library does not touch the
filesystem; the caller produces snapshots from a real walk, mocks,
or a log replay.

```gleam
import automata/fsevent
import gleam/option.{Some}

pub fn detect_change() {
  let assert Ok(p) = fsevent.normalize("/tmp/a.log")
  let assert Ok(prev) =
    fsevent.entry_file(
      path: p,
      size: 100,
      mtime: 1_700_000_000,
      mode: 0o644,
      content_hash: Some("abc"),
      file_id: Some("inode-1"),
    )
  let assert Ok(curr) =
    fsevent.entry_file(
      path: p,
      size: 200,
      mtime: 1_700_000_500,
      mode: 0o644,
      content_hash: Some("def"),
      file_id: Some("inode-1"),
    )
  let assert Ok(prev_snap) = fsevent.from_entries([prev])
  let assert Ok(curr_snap) = fsevent.from_entries([curr])

  fsevent.diff(prev: prev_snap, curr: curr_snap, watch: fsevent.watch())
}
```

When both snapshots carry the same `file_id` at different paths, a
move is reported as a single `Rename` event with `renamed_from`
attached. Without `file_id`, the move falls back to `Remove` plus
`Create`. Op masks (`fsevent.with_ops`, `fsevent.unwatch_op`) drop
unwanted ops before they reach the result list.

## Retry

```gleam
import automata/retry
import automata/retry/ast as retry_ast

pub fn http_backoff() {
  let assert Ok(initial) = retry_ast.from_milliseconds(milliseconds: 100)
  let assert Ok(cap) = retry_ast.from_seconds(seconds: 30)
  let assert Ok(base) =
    retry.capped_exponential(
      initial: initial,
      multiplier: 2,
      cap: cap,
      max_attempts: 6,
    )
  retry.with_jitter(policy: base, jitter: retry_ast.FullJitter)
}

pub fn drive(policy) {
  let ctx0 = retry.start(policy: policy, seed: 12_345)

  case retry.decide(ctx: ctx0, failure: retry_ast.Transient) {
    retry.Retry(delay: delay, next: _next) -> delay
    retry.GiveUp(reason: _reason) -> retry_ast.unsafe_milliseconds(value: 0)
  }
}
```

`max_attempts: N` means N total attempts (one initial try plus
`N - 1` retries). The retry module never sleeps; the caller drives
the loop and sums `retry.cumulative_delay/1` to apply a wall-clock
deadline. The bundled PRNG runs in pure Gleam, so the same
`(policy, seed, failure-sequence)` triple produces the same
`Decision` list on the BEAM and the JavaScript target.

## Validated date-time

`automata/schedule/ast` exposes `try_datetime/6` and
`try_valid_datetime/6`, which return `Result` when components fall
outside the Gregorian range. The opaque `ValidDateTime` then flows
through cron, RRULE, schedule, and event APIs without further
revalidation.

## Documentation

Full API reference: <https://hexdocs.pm/automata>.

Development setup, code style, and the contribution flow are
documented in [CONTRIBUTING.md](CONTRIBUTING.md). Released changes
are tracked in [CHANGELOG.md](CHANGELOG.md).

## License

MIT