Skip to main content

lib/systemd/calendar.ex

defmodule Systemd.Calendar do
  @moduledoc "Helpers for building systemd calendar expressions."

  @weekdays %{
    monday: "Mon",
    mon: "Mon",
    tuesday: "Tue",
    tue: "Tue",
    wednesday: "Wed",
    wed: "Wed",
    thursday: "Thu",
    thu: "Thu",
    friday: "Fri",
    fri: "Fri",
    saturday: "Sat",
    sat: "Sat",
    sunday: "Sun",
    sun: "Sun"
  }

  @doc "Builds a daily calendar expression at a time of day."
  @spec daily_at(String.t() | Time.t()) :: String.t()
  def daily_at(time), do: "*-*-* #{time!(time)}"

  @doc "Builds a weekly calendar expression for a weekday and time of day."
  @spec weekly_at(atom() | String.t(), String.t() | Time.t()) :: String.t()
  def weekly_at(day, time), do: "#{weekday!(day)} *-*-* #{time!(time)}"

  @doc "Builds a monthly calendar expression for a day of month and time of day."
  @spec monthly_at(pos_integer(), String.t() | Time.t()) :: String.t()
  def monthly_at(day, time) when is_integer(day) and day in 1..31 do
    "*-*-#{String.pad_leading(to_string(day), 2, "0")} #{time!(time)}"
  end

  def monthly_at(day, _time) do
    raise ArgumentError, "monthly timer day must be an integer from 1 to 31, got: #{inspect(day)}"
  end

  defp time!(%Time{} = time) do
    time
    |> Time.truncate(:second)
    |> Time.to_iso8601()
  end

  defp time!(value) when is_binary(value) do
    value
    |> normalize_time_string()
    |> Time.from_iso8601()
    |> case do
      {:ok, time} ->
        time!(time)

      {:error, _reason} ->
        raise ArgumentError,
              "expected timer time as HH:MM or HH:MM:SS, got: #{inspect(value)}"
    end
  end

  defp time!(value) do
    raise ArgumentError, "expected timer time as HH:MM, HH:MM:SS, or Time, got: #{inspect(value)}"
  end

  defp normalize_time_string(<<_h1, _h2, ?:, _m1, _m2>> = time), do: time <> ":00"
  defp normalize_time_string(time), do: time

  defp weekday!(day) when is_atom(day) do
    case Map.fetch(@weekdays, day) do
      {:ok, weekday} -> weekday
      :error -> raise ArgumentError, "unknown weekday #{inspect(day)}"
    end
  end

  defp weekday!(day) when is_binary(day) do
    weekday = String.downcase(day)

    @weekdays
    |> Enum.find_value(fn {key, value} -> if Atom.to_string(key) == weekday, do: value end)
    |> case do
      nil -> raise ArgumentError, "unknown weekday #{inspect(day)}"
      value -> value
    end
  end
end