Skip to main content

lib/mix/tasks/scoria.warning_baseline.check.ex

defmodule Mix.Tasks.Scoria.WarningBaseline.Check do
  use Mix.Task

  @shortdoc "Fails when accepted warning debt rows are expired or invalid"

  @moduledoc """
  Validates accepted warning debt rows in `.planning/WARNING-BASELINE.md`.

  Expiry uses UTC calendar dates. Rows remain valid through the end of their expiry day.

  ## Examples

      mix scoria.warning_baseline.check
      mix scoria.warning_baseline.check --file test/fixtures/warning_baseline/valid.md --date 2026-01-01

  ## Options

    * `--file` - baseline markdown path (default `.planning/WARNING-BASELINE.md`)
    * `--date` - check date as `YYYY-MM-DD` (default UTC today)
  """

  alias Scoria.WarningBaseline
  alias Scoria.WarningBaseline.Row

  @switches [file: :string, date: :string]

  @impl Mix.Task
  def run(args) do
    {opts, _, invalid} = OptionParser.parse(args, strict: @switches)

    if invalid != [] do
      Mix.raise("invalid options: #{inspect(invalid)}")
    end

    file = resolve_file!(Keyword.get(opts, :file, ".planning/WARNING-BASELINE.md"))
    date = parse_date!(Keyword.get(opts, :date))

    baseline = WarningBaseline.load(file: file, date: date)

    case {WarningBaseline.invalid_rows(baseline), WarningBaseline.expired_rows(baseline)} do
      {[], []} ->
        Mix.shell().info("==> Warning baseline check passed")
        exit({:shutdown, 0})

      {invalid_rows, _} when invalid_rows != [] ->
        print_invalid_rows(invalid_rows)
        exit({:shutdown, 1})

      {_, expired_rows} ->
        print_expired_rows(expired_rows, date)
        print_remediation()
        exit({:shutdown, 1})
    end
  end

  defp resolve_file!(path) do
    if String.contains?(path, "..") do
      Mix.raise("invalid --file path: path traversal is not allowed")
    end

    cwd = File.cwd!()
    expanded = Path.expand(path, cwd)

    unless String.starts_with?(expanded, cwd) do
      Mix.raise("invalid --file path: must resolve under project root")
    end

    unless File.regular?(expanded) do
      Mix.raise("baseline file not found: #{path}")
    end

    expanded
  end

  defp parse_date!(nil), do: Date.utc_today()

  defp parse_date!(value) do
    case Date.from_iso8601(value) do
      {:ok, date} -> date
      {:error, _} -> Mix.raise("invalid --date value: expected YYYY-MM-DD, got #{inspect(value)}")
    end
  end

  defp print_invalid_rows(rows) do
    Mix.shell().error("==> Warning baseline check failed: invalid accepted rows")

    Mix.shell().error(
      String.pad_trailing("Surface", 36) <>
        String.pad_trailing("Owner", 24) <>
        String.pad_trailing("Expires", 14) <> "Issue"
    )

    for row <- rows do
      expires_label = expires_label(row)

      Mix.shell().error(
        String.pad_trailing(row.surface, 36) <>
          String.pad_trailing(display(row.owner), 24) <>
          String.pad_trailing(expires_label, 14) <> "missing owner or expiry"
      )
    end
  end

  defp print_expired_rows(rows, date) do
    Mix.shell().error("==> Warning baseline check failed: expired accepted rows")

    Mix.shell().error(
      String.pad_trailing("Surface", 36) <>
        String.pad_trailing("Expires", 14) <>
        String.pad_trailing("Owner", 24) <> "Days overdue"
    )

    for row <- rows do
      days_overdue = Date.diff(date, row.expires)

      Mix.shell().error(
        String.pad_trailing(row.surface, 36) <>
          String.pad_trailing(Date.to_iso8601(row.expires), 14) <>
          String.pad_trailing(row.owner, 24) <> Integer.to_string(days_overdue)
      )
    end
  end

  defp print_remediation do
    Mix.shell().error("")
    Mix.shell().error("Remediation:")
    Mix.shell().error("* Fix the warning debt in code and remove the accepted row.")
    Mix.shell().error("* Move the row to the Resolved section with a resolution date.")
    Mix.shell().error("* Re-baseline with a new owner, expiry, and reason in the same PR.")
  end

  defp expires_label(%Row{expires: %Date{} = date}), do: Date.to_iso8601(date)
  defp expires_label(%Row{}), do: ""

  defp display(value) when is_binary(value), do: value
  defp display(_), do: ""
end