Skip to main content

lib/mix/tasks/host_kit.runs.ex

defmodule Mix.Tasks.HostKit.Runs do
  @moduledoc """
  Lists minimal HostKit run records.
  """

  use Mix.Task

  alias Mix.Tasks.HostKit.Options

  @shortdoc "List tracked HostKit runs"

  @impl true
  def run(args) do
    Mix.Task.run("app.start")

    {opts, positional} =
      OptionParser.parse!(args,
        strict: [
          local: :boolean,
          host: :string,
          remote: :string,
          user: :string,
          port: :integer,
          identity_file: :string,
          password: :string,
          password_env: :string,
          silently_accept_hosts: :boolean,
          sudo: :boolean,
          require: :keep,
          runs_root: :string,
          format: :string,
          verbose: :boolean,
          latest: :boolean,
          id: :string,
          prune: :boolean,
          keep: :integer
        ]
      )

    project = load_project(opts, positional)

    Options.with_target_opts(opts, project, fn target_opts ->
      opts
      |> run_opts(target_opts)
      |> run_command(opts)
    end)
  end

  defp run_command(run_opts, opts) do
    if Keyword.get(opts, :prune, false),
      do: prune_runs(run_opts, opts),
      else: list_runs(run_opts, opts)
  end

  defp prune_runs(run_opts, opts) do
    case HostKit.RunRecord.prune(run_opts, keep: Keyword.get(opts, :keep, 20)) do
      {:ok, pruned} -> IO.puts("pruned #{length(pruned)} HostKit run(s)")
      {:error, reason} -> Mix.raise("could not prune HostKit runs: #{inspect(reason)}")
    end
  end

  defp list_runs(run_opts, opts) do
    case load_records(opts, run_opts) do
      {:ok, records} -> IO.puts(format_records(records, opts))
      {:error, reason} -> Mix.raise("could not list HostKit runs: #{inspect(reason)}")
    end
  end

  defp load_project(opts, positional) do
    if Keyword.has_key?(opts, :host) do
      path = List.first(positional) || "infra/config.exs"
      HostKit.load!(path, require: Keyword.get_values(opts, :require))
    end
  end

  defp run_opts(opts, target_opts) do
    target_opts
    |> Options.expand_target_opts()
    |> put_present(:hostkit_runs_root, Keyword.get(opts, :runs_root))
  end

  defp load_records(opts, run_opts) do
    cond do
      id = Keyword.get(opts, :id) ->
        with {:ok, record} <- HostKit.RunRecord.load(id, run_opts), do: {:ok, [record]}

      Keyword.get(opts, :latest, false) ->
        with {:ok, record} <- HostKit.RunRecord.latest(run_opts), do: {:ok, [record]}

      true ->
        HostKit.RunRecord.list(run_opts)
    end
  end

  defp format_records(records, opts) do
    case Keyword.get(opts, :format, "text") do
      "json" -> records |> Enum.map(&JSONCodec.dump/1) |> Jason.encode!(pretty: true)
      "inspect" -> inspect(records, pretty: true, limit: :infinity)
      "text" -> Enum.map_join(records, "\n", &format_record(&1, opts))
    end
  end

  defp format_record(record, opts) do
    summary =
      [
        record.id,
        record.direction,
        record.project,
        record.applied_at,
        "changes=#{length(record.changes)}",
        "artifacts=#{map_size(record.artifacts || %{})}",
        "backups=#{map_size(record.backups || %{})}"
      ]
      |> Enum.reject(&is_nil/1)
      |> Enum.join(" ")

    if Keyword.get(opts, :verbose, false) do
      [
        summary,
        format_paths("artifacts", record.artifacts),
        format_paths("backups", record.backups)
      ]
      |> Enum.reject(&(&1 == ""))
      |> Enum.join("\n")
    else
      summary
    end
  end

  defp format_paths(_label, nil), do: ""
  defp format_paths(_label, map) when map_size(map) == 0, do: ""

  defp format_paths(label, map) do
    lines =
      map
      |> Enum.sort_by(fn {key, _path} -> key end)
      |> Enum.map(fn {key, path} -> "  #{label}.#{key}=#{path}" end)

    Enum.join(lines, "\n")
  end

  defp put_present(opts, _key, nil), do: opts
  defp put_present(opts, key, value), do: Keyword.put(opts, key, value)
end