Lesson

Parse a Schedule File

Read a schedule file, handle all three types, write tests.

You've seen schedule files — 60, {"type": "scheduled", "calendar": {...}}, {"type": "on-change", "paths": [...]}. Now you'll build the parser that reads them.

The Function

parseScheduleText takes the raw string contents of a schedule file and returns a structured object the manager can work with.

The inputs are messy — plain integers, JSON objects, empty files, garbage. The output is clean:

type ParsedSchedule =
  | { kind: "periodic"; seconds: number; runAtLoad?: boolean }
  | { kind: "scheduled"; calendar: Record<string, unknown>; runAtLoad?: boolean }
  | { kind: "on-change"; paths: string[]; runAtLoad?: boolean };

interface ParsedScheduleFile {
  schedule?: ParsedSchedule;
  program?: string;
  disabled?: boolean;
}

Build It

Open src/manager.ts and find the parseScheduleText function. Here's the logic:

  1. Trim whitespace. Empty string → { disabled: true }.
  2. Plain integer (regex: /^\d+$/) → periodic schedule with that many seconds.
  3. JSON parse. If it fails → { disabled: true }.
  4. Check for disabled: true in the JSON → disabled.
  5. Check the type field → route to periodic, scheduled, or on-change.
  6. No type field? Infer from shape — seconds means periodic, calendar means scheduled, paths means on-change.

The calendar keys need normalizing — users might write hour but launchd expects Hour. The normalizeCalendarForLaunchd function handles this.

Write Tests

Create src/manager.test.ts:

import { describe, test, expect } from "bun:test";
import { parseScheduleText, normalizeCalendarForLaunchd } from "./manager";

describe("parseScheduleText — periodic", () => {
  test("plain integer string", () => {
    const result = parseScheduleText("60");
    expect(result.schedule).toEqual({ kind: "periodic", seconds: 60 });
  });

  test("JSON with type: periodic", () => {
    const result = parseScheduleText('{"type": "periodic", "seconds": 120}');
    expect(result.schedule).toEqual({ kind: "periodic", seconds: 120 });
  });

  test("infers periodic from seconds field without type", () => {
    const result = parseScheduleText('{"seconds": 90}');
    expect(result.schedule).toEqual({ kind: "periodic", seconds: 90 });
  });
});

Run them:

bun test

Now write tests for the other two types. For each type, cover:

  • The explicit type field format
  • The inferred format (no type, just shape)
  • Edge cases (empty string, invalid JSON, disabled flag)

Verification

bun test

All tests should pass. If you get stuck, look at the three schedule files in system-jobs/ — they're working examples of each type.

What You Learned

  • Schedule files are intentionally loose — plain integers for simple cases, JSON for everything else
  • The parser is defensive — bad input → disabled, never a crash
  • Calendar key normalization (hourHour) is a small thing that prevents a confusing launchd error
  • Tests are the fastest way to understand a parser — write the test, see what the function actually does