lib/git_hub_actions/workflow.ex

defmodule GitHubActions.Workflow do
  @moduledoc """
  The `GitHubActions.Workflow` is used to create a GitHub actions workflow.

  ```elixir
  defmodule Minimal do
    use GitHubActions.Workflow

    def workflow do
      [
        name: "CI"
      ]
    end
  end
  ```

  The workflow module must define the `workflow/0` function. This function
  returns a nested data structure that will be translated in a yml-file.

  The line `use GitHubActions.Workflow` imports `GitHubActions.Workflow`,
  `GitHubActions.Mix` and `GitHubActions.Sigils` and adds the aliases
  `GitHubActions.Config`, `GitHubActions.Project` and `GitHubActions.Versions`.

  List entries with the value `:skip` are not taken over.

  Key-value pairs with a value of `:skip` are also not part of the resulting
  data structure.

  With :skip, you can handle optional parts in a workflow script.

  ```elixir
  defmodule Simple do
    use GitHubActions.Workflow

    def workflow do
      [
        name: "CI",
        jobs: [
          linux: linux(),
          os2: os2()
        ]
      ]
    end

    defp linux do
      job(:linux,
        name: "Test on \#{Config.fetch!([:linux, :name])}",
        runs_on: Config.fetch!([:linux, :runs_on])
      )
    end

    defp os2 do
      job(:linux,
        name: "Test on \#{Config.fetch!([:os2, :name])}",
        runs_on: Config.fetch!([:os2, :runs_on])
      )
    end

    defp job(os, cofig) do
      case :jobs |> Config.get([]) |> Enum.member?(os) do
        true -> config
        false -> :skip
      end
    end
  end
  ```

  It is also possible to add steps when a dependency is available in the current
  project.

  ```elixir
  defmodule Simple do
    use GitHubActions.Workflow

    def workflow do
      [
        name: "CI",
        jobs: [linux: linux()]
      ]
    end

    defp linux do
      name: "Test on \#{Config.fetch!([:linux, :name])}",
      runs_on: Config.fetch!([:linux, :runs_on])
      steps: [
        checkout(),
        check_code_format(),
        lint_code()
      ]
    end

    defp checkout do
      [
        name: "Checkout",
        uses: "actions/checkout@v3"
      ]
    end

    defp lint_code do
      case Project.has_dep?(:credo) do
        false ->
          :skip

        true ->
          [
            name: "Lint code",
            run: mix(:credo, strict: true, env: :test)
          ]
      end
    end

    defp check_code_format do
      case Config.get(:check_code_format, true) do
        false ->
          :skip

        true ->
          [
            name: "Check code format",
            run: mix(:format, check_formatted: true, env: :test)
          ]
      end
    end
  end
  ```
  """

  alias GitHubActions.ConvCase

  defmacro __using__(_opts) do
    quote do
      import GitHubActions.Workflow
      import GitHubActions.Mix
      import GitHubActions.Sigils

      alias GitHubActions.Config
      alias GitHubActions.Project
      alias GitHubActions.Versions
    end
  end

  @doc """
  Evaluates a workflow script and returns the workflow data structure.
  """
  @spec eval(Path.t()) :: {:ok, term()} | :error
  def eval(file) do
    with {:ok, module} <- compile(file),
         {:ok, workflow} <- workflow(module) do
      {:ok, map(workflow)}
    end
  end

  defp workflow(module) do
    case function_exported?(module, :workflow, 0) do
      true -> {:ok, module.workflow()}
      false -> {:error, :workflow}
    end
  end

  defp compile(file) do
    case file |> File.read!() |> Code.eval_string([], file: file) do
      {{:module, module, _bin, _meta}, _bind} -> {:ok, module}
      _else -> :error
    end
  end

  defp map({_key, :skip}), do: :skip

  defp map({key, value}) do
    {ConvCase.to_kebab(key), map(value)}
  end

  defp map(workflow) when is_list(workflow) do
    workflow
    |> Enum.reduce([], fn item, acc ->
      case map(item) do
        :skip -> acc
        value -> [value | acc]
      end
    end)
    |> Enum.reverse()
  end

  defp map(:skip), do: :skip

  defp map(value), do: to_string(value)
end