Skip to main content

lib/oban/web/cron_expr.ex

defmodule Oban.Web.CronExpr do
  @moduledoc false

  @days_of_week_names %{
    0 => "Sunday",
    1 => "Monday",
    2 => "Tuesday",
    3 => "Wednesday",
    4 => "Thursday",
    5 => "Friday",
    6 => "Saturday"
  }

  @days_of_week_translations %{
    "SUN" => 0,
    "MON" => 1,
    "TUE" => 2,
    "WED" => 3,
    "THU" => 4,
    "FRI" => 5,
    "SAT" => 6
  }

  @doc """
  Convert a cron expression into a human-readable description.

  Returns a friendly string for common patterns, or nil for complex patterns
  that don't match known templates.
  """
  def describe(expression) when is_binary(expression) do
    case expression do
      "@yearly" -> "Yearly on January 1st"
      "@annually" -> "Yearly on January 1st"
      "@monthly" -> "Monthly on the 1st"
      "@weekly" -> "Weekly on Sunday"
      "@daily" -> "Daily at midnight"
      "@midnight" -> "Daily at midnight"
      "@hourly" -> "Every hour"
      "@reboot" -> "At system reboot"
      _ -> describe_parsed(expression)
    end
  end

  def describe(_), do: nil

  defp describe_parsed(expression) do
    with [min, hrs, dom, "*", dow] <- String.split(expression, " ", parts: 5),
         {:ok, parsed_min} <- parse_field(min, 0..59),
         {:ok, parsed_hrs} <- parse_field(hrs, 0..23),
         {:ok, parsed_dom} <- parse_field(dom, 1..31),
         {:ok, parsed_dow} <- parse_field(dow, 0..7, @days_of_week_translations) do
      combine_description(parsed_min, parsed_hrs, parsed_dom, parsed_dow)
    else
      _ -> nil
    end
  end

  # Field Parsing

  defp parse_field(field, bounds, translations \\ %{}) do
    parts =
      field
      |> String.split(",")
      |> Enum.map(&parse_part(&1, bounds, translations))

    if Enum.any?(parts, &(&1 == :error)) do
      :error
    else
      {:ok, parts}
    end
  end

  defp parse_part("*", _bounds, _translations), do: :wildcard

  defp parse_part("*/" <> step, bounds, _translations) do
    case Integer.parse(step) do
      {num, ""} when num > bounds.first and num <= bounds.last -> {:step, num}
      _ -> :error
    end
  end

  defp parse_part(part, bounds, translations) do
    if String.contains?(part, "-") do
      parse_range(part, bounds, translations)
    else
      parse_value(part, bounds, translations)
    end
  end

  defp parse_range(part, bounds, translations) do
    with [start_str, end_str] <- String.split(part, "-", parts: 2),
         {:ok, start_val} <- translate_or_parse(start_str, translations),
         {:ok, end_val} <- translate_or_parse(end_str, translations),
         true <- start_val in bounds and end_val in bounds and end_val >= start_val do
      {:range, start_val, end_val}
    else
      _ -> :error
    end
  end

  defp parse_value(part, bounds, translations) do
    with {:ok, val} <- translate_or_parse(part, translations),
         true <- val in bounds do
      {:value, val}
    else
      _ -> :error
    end
  end

  defp translate_or_parse(str, translations) do
    upper = String.upcase(str)

    with :error <- Map.fetch(translations, upper) do
      case Integer.parse(str) do
        {num, ""} -> {:ok, num}
        _ -> :error
      end
    end
  end

  # Description Combination

  defp combine_description(min, hrs, dom, dow) do
    if dom == [:wildcard] and dow == [:wildcard] do
      describe_time(min, hrs)
    else
      combine_date_time(min, hrs, dom, dow)
    end
  end

  defp describe_time([:wildcard], [:wildcard]), do: "Every minute"
  defp describe_time([{:step, 1}], [:wildcard]), do: "Every minute"
  defp describe_time([{:step, step}], [:wildcard]), do: "Every #{step} minutes"
  defp describe_time([{:value, 0}], [:wildcard]), do: "Every hour"
  defp describe_time([{:value, 0}], [{:step, 1}]), do: "Every hour"
  defp describe_time([{:value, 0}], [{:step, step}]), do: "Every #{step} hours"

  defp describe_time([{:value, min}], [{:step, step}]) when min in 0..59 do
    "Every #{step} hours at :#{String.pad_leading("#{min}", 2, "0")}"
  end

  defp describe_time([{:value, 0}], [{:value, 0}]), do: "Daily at midnight"
  defp describe_time([{:value, 0}], [{:value, 12}]), do: "Daily at noon"

  defp describe_time([{:value, min}], [{:value, hr}]) when min in 0..59 and hr in 0..23 do
    "Daily at #{format_time_24h(hr, min)}"
  end

  defp describe_time([{:value, min}], hours) when is_list(hours) and min in 0..59 do
    times =
      Enum.map(hours, fn
        {:value, hr} when hr in 0..23 -> format_time_24h(hr, min)
        _ -> nil
      end)

    if Enum.any?(times, &is_nil/1) do
      nil
    else
      "Daily at #{format_list(times)}"
    end
  end

  defp describe_time(_, _), do: nil

  defp combine_date_time(min, hrs, dom, dow) do
    time = extract_time(min, hrs)
    date_desc = describe_date(dom, dow)

    case {time, date_desc} do
      {nil, _} -> nil
      {_, nil} -> nil
      {{0, 0}, desc} -> "#{desc} at 0:00"
      {{hrs, min}, desc} -> "#{desc} at #{format_time_24h(hrs, min)}"
    end
  end

  defp extract_time([{:value, min}], [{:value, hrs}]) when min in 0..59 and hrs in 0..23 do
    {hrs, min}
  end

  defp extract_time(_, _), do: nil

  # Date Description (DOM and DOW combination)

  defp describe_date([:wildcard], dow) do
    describe_dow(dow)
  end

  defp describe_date(dom, [:wildcard]) do
    describe_dom(dom)
  end

  defp describe_date(dom, dow) do
    describe_combined_dom_dow(dom, dow)
  end

  # Day of Month Description

  defp describe_dom([{:value, day}]) when day in 1..31 do
    "Monthly on the #{ordinal(day)}"
  end

  defp describe_dom(parts) when is_list(parts) do
    values = expand_dom_parts(parts)

    case excluded_dom(values) do
      {:ok, day} ->
        "Daily except the #{ordinal(day)}"

      :error ->
        days = extract_dom_values(parts)

        if days != [] do
          "On the #{format_list(Enum.map(days, &ordinal/1))}"
        else
          nil
        end
    end
  end

  defp describe_dom(_), do: nil

  defp excluded_dom(values) do
    case Enum.to_list(1..31) -- values do
      [excluded] -> {:ok, excluded}
      _ -> :error
    end
  end

  defp expand_dom_parts(parts) do
    parts
    |> Enum.flat_map(fn
      {:value, val} -> [val]
      {:range, start_val, end_val} -> Enum.to_list(start_val..end_val)
      _ -> []
    end)
    |> Enum.sort()
  end

  defp extract_dom_values(parts) do
    Enum.flat_map(parts, fn
      {:value, val} when val in 1..31 ->
        [val]

      {:range, start_val, end_val} when start_val in 1..31 and end_val in 1..31 ->
        Enum.to_list(start_val..end_val)

      _ ->
        []
    end)
  end

  # Day of Week Description

  defp describe_dow([{:value, day}]) when day in 0..7 do
    "Weekly on #{day_name(normalize_dow(day))}"
  end

  defp describe_dow(parts) when is_list(parts) do
    values = expand_dow_parts(parts)

    cond do
      values == [1, 2, 3, 4, 5] ->
        "Weekdays"

      values == [0, 6] ->
        "Weekends"

      length(values) == 6 ->
        {:ok, day} = excluded_dow(values)

        "Daily except #{day_name(day)}s"

      values != [] ->
        "On #{format_list(Enum.map(values, &day_name/1))}"

      true ->
        nil
    end
  end

  defp describe_dow(_), do: nil

  defp expand_dow_parts(parts) do
    parts
    |> Enum.flat_map(fn
      {:value, val} -> [normalize_dow(val)]
      {:range, start_val, end_val} -> Enum.map(start_val..end_val, &normalize_dow/1)
      _ -> []
    end)
    |> Enum.uniq()
    |> Enum.sort()
  end

  defp normalize_dow(7), do: 0
  defp normalize_dow(day), do: day

  defp excluded_dow(values) do
    case Enum.to_list(0..6) -- values do
      [excluded] -> {:ok, excluded}
      _ -> :error
    end
  end

  # Combined DOM and DOW description

  defp describe_combined_dom_dow(dom, dow) do
    dom_values = expand_dom_parts(dom)
    dow_values = expand_dow_parts(dow)

    case {dom_values, excluded_dom(dom_values), dow_values, excluded_dow(dow_values)} do
      {[dom_day], _, [dow_day], _} ->
        "The #{ordinal(dom_day)}, only on #{day_name(dow_day)}s"

      {[dom_day], _, _, {:ok, excluded}} ->
        "The #{ordinal(dom_day)}, except #{day_name(excluded)}s"

      {_, {:ok, excluded}, [dow_day], _} ->
        "#{day_name(dow_day)}s, except the #{ordinal(excluded)}"

      {_, {:ok, dom_excluded}, _, {:ok, dow_excluded}} ->
        "Daily except the #{ordinal(dom_excluded)} and #{day_name(dow_excluded)}s"

      _ ->
        nil
    end
  end

  # Helper functions

  defp day_name(num), do: Map.get(@days_of_week_names, num, "Unknown")

  defp format_time_24h(hr, min) do
    "#{hr}:#{String.pad_leading("#{min}", 2, "0")}"
  end

  defp format_list([single]), do: single
  defp format_list([first, second]), do: "#{first} and #{second}"

  defp format_list(items) when length(items) > 2 do
    {init, [last]} = Enum.split(items, -1)
    "#{Enum.join(init, ", ")}, and #{last}"
  end

  defp format_list(_), do: nil

  defp ordinal(num) when num in [1, 21, 31], do: "#{num}st"
  defp ordinal(num) when num in [2, 22], do: "#{num}nd"
  defp ordinal(num) when num in [3, 23], do: "#{num}rd"
  defp ordinal(num), do: "#{num}th"
end