6.2 KiB · text History b868907
defmodule GitGud.Badges.Spec do
  @moduledoc """
  What a badge URL asks for, as path segments rather than a query string:

      /r/:owner/:name/badge/ci.svg
      /r/:owner/:name/badge/ci/dot/2.5x/gg/forest.png
      /r/:owner/:name/badge/coverage/outline/square/light.svg
      /r/:owner/:name/badge/workflow/audit/dot.svg
      /r/:owner/:name/badge/package/cargo/gitgud-cli/2x.png

  The first segment is the kind. `workflow` takes the workflow's name as
  the next one (its `name:` or its file's basename), and `package` the
  registry format and the package's name as the next two. The rest are
  options in any order,
  each at most once, with the format on the last segment. Anything left
  out takes its default, and `to_path/1` writes them back in one
  canonical order with the defaults dropped, so a badge has one URL.

  The badge sheet (`/r/:owner/:name/badges/…`) takes the same option
  segments minus kind, theme and format, so the sheet's URL and the
  images it previews say the same thing.
  """

  alias GitGud.Packages.Format

  @kinds ~w(ci ci-duration last-commit coverage workflow package issues pulls commits
             contributors release license mirror language syms mark)
  @named_kinds ~w(workflow package)
  @styles ~w(split dot outline)
  @corners ~w(round square)
  @scales %{"1x" => 1.0, "2x" => 2.0, "2.5x" => 2.5}
  @scale_order ~w(1x 2x 2.5x)
  @formats ~w(svg png)

  @type t :: %__MODULE__{
          kind: String.t(),
          name: String.t() | nil,
          registry: String.t() | nil,
          style: String.t(),
          corners: String.t(),
          scale: String.t(),
          gg: boolean(),
          theme: String.t(),
          format: String.t()
        }

  defstruct kind: "ci",
            name: nil,
            registry: nil,
            style: "split",
            corners: "round",
            scale: "1x",
            gg: false,
            theme: "auto",
            format: "svg"

  def kinds, do: @kinds
  def styles, do: @styles
  def corners, do: @corners
  def scales, do: @scale_order
  def formats, do: @formats
  def themes, do: ["auto" | GitGud.Themes.all()]

  @doc "The numeric zoom for a scale token: `\"2.5x\"` is `2.5`."
  def zoom(%__MODULE__{scale: scale}), do: Map.fetch!(@scales, scale)

  @doc """
  Parse a badge image path's segments, e.g.
  `["ci", "dot", "2.5x", "forest.png"]`.
  """
  @spec parse_path([String.t()]) :: {:ok, t()} | :error
  def parse_path([]), do: :error

  def parse_path(segments) do
    {init, [last]} = Enum.split(segments, -1)
    ext = Path.extname(last)
    format = String.trim_leading(ext, ".")

    with true <- format in @formats,
         {:ok, spec, tokens} <- take_kind(init ++ [Path.rootname(last, ext)]),
         {:ok, spec} <- apply_tokens(%{spec | format: format}, tokens, true) do
      {:ok, spec}
    else
      _ -> :error
    end
  end

  defp take_kind(["workflow", name | tokens]) do
    if valid_name?(name),
      do: {:ok, %__MODULE__{kind: "workflow", name: name}, tokens},
      else: :error
  end

  defp take_kind(["package", registry, name | tokens]) do
    if registry in Format.formats() and valid_name?(name),
      do: {:ok, %__MODULE__{kind: "package", registry: registry, name: name}, tokens},
      else: :error
  end

  defp take_kind([kind | tokens]) when kind in @kinds and kind not in @named_kinds,
    do: {:ok, %__MODULE__{kind: kind}, tokens}

  defp take_kind(_), do: :error

  @doc "Whether `name` can name a workflow in a badge path."
  def valid_name?(name),
    do:
      is_binary(name) and String.valid?(name) and byte_size(name) in 1..100 and
        name =~ ~r/^[^\x00-\x1f]+$/

  @doc """
  Parse the badge sheet's option segments (style, corners, scale, `gg`).
  """
  @spec parse_options([String.t()]) :: {:ok, t()} | :error
  def parse_options(segments), do: apply_tokens(%__MODULE__{}, segments, false)

  defp apply_tokens(spec, tokens, theme?) do
    Enum.reduce_while(tokens, {:ok, spec, MapSet.new()}, fn token, {:ok, spec, seen} ->
      case classify(token, theme?) do
        {field, value} ->
          if MapSet.member?(seen, field),
            do: {:halt, :error},
            else: {:cont, {:ok, Map.put(spec, field, value), MapSet.put(seen, field)}}

        nil ->
          {:halt, :error}
      end
    end)
    |> case do
      {:ok, spec, _seen} -> {:ok, spec}
      :error -> :error
    end
  end

  defp classify(token, theme?) do
    cond do
      token in @styles -> {:style, token}
      token in @corners -> {:corners, token}
      Map.has_key?(@scales, token) -> {:scale, token}
      token == "gg" -> {:gg, true}
      theme? and token in themes() -> {:theme, token}
      true -> nil
    end
  end

  @doc """
  The canonical image path below `/r/:owner/:name/badge/`, defaults
  dropped: `ci.svg`, `ci/dot/2.5x/gg/forest.png`.
  """
  @spec to_path(t()) :: String.t()
  def to_path(%__MODULE__{} = spec) do
    theme = if spec.theme == "auto", do: [], else: [spec.theme]

    (kind_segments(spec) ++ option_tokens(spec) ++ theme)
    |> Enum.join("/")
    |> Kernel.<>(".#{spec.format}")
  end

  @doc ~s(The kind's own segments: `["ci"]`, `["workflow", "audit"]`, `["package", "cargo", "x"]`.)
  @spec kind_segments(t()) :: [String.t()]
  def kind_segments(%__MODULE__{kind: "workflow", name: name}), do: ["workflow", name]

  def kind_segments(%__MODULE__{kind: "package", registry: registry, name: name}),
    do: ["package", registry, name]

  def kind_segments(%__MODULE__{kind: kind}), do: [kind]

  @doc ~s(The badge sheet's option segments, `["dot", "2.5x", "gg"]`, defaults dropped.)
  @spec option_segments(t()) :: [String.t()]
  def option_segments(%__MODULE__{} = spec), do: option_tokens(spec)

  @doc """
  The segments after the kind with the theme always written out, even
  `auto`: `[\"dot\", \"auto.svg\"]`. For URLs, like the sheet's samples,
  that need at least one segment there.
  """
  @spec explicit_segments(t()) :: [String.t()]
  def explicit_segments(%__MODULE__{} = spec),
    do: option_tokens(spec) ++ ["#{spec.theme}.#{spec.format}"]

  defp option_tokens(spec) do
    default = %__MODULE__{}

    [
      spec.style != default.style && spec.style,
      spec.corners != default.corners && spec.corners,
      spec.scale != default.scale && spec.scale,
      spec.gg && "gg"
    ]
    |> Enum.filter(& &1)
  end
end