lib/mix/tasks/foundry.lint.all.ex

defmodule Mix.Tasks.Foundry.Lint.All do
  @shortdoc "Run all Foundry lint rules against the current project (INV-001 through INV-013)"

  @moduledoc """
  Runs the full Foundry lint suite against all compiled modules and the project
  manifest. Emits a JSON report and exits non-zero if any `:error` severity
  violations are found.

  ## Usage

      mix foundry.lint.all
      mix foundry.lint.all --json
      mix foundry.lint.all --format=text      # human-readable summary

  ## Exit codes

  - `0` — no `:error` violations (warnings and info are non-blocking)
  - `1` — one or more `:error` violations found
  - `2` — lint runner itself failed (bug in a rule or runner crash)

  ## CI integration

  Add to your CI pipeline:

      - run: mix foundry.lint.all

  The lint report is emitted to stdout. CI systems can capture it for
  artefact upload. Violations are printed in a stable order (by file path,
  then rule ID) so diffs between CI runs are meaningful.

  ## Violations

  Violations are sorted: errors first, then warnings, then info.
  Within each severity, sorted by module name then rule_id.

  ## JSON output shape

  Matches `Foundry.Lint.LintReport`. The `passed` field is `true` iff
  `error_count == 0`.
  """

  use Mix.Task

  alias Foundry.Lint.Runner

  @impl Mix.Task
  def run(args) do
    app = Mix.Project.config()[:app]
    Application.put_env(app, :foundry_tasks_only, true)

    Mix.Task.run("app.start")

    format = parse_format(args)
    project_root = File.cwd!()

    report = Runner.run(project_root)

    sorted_report = %{
      report
      | violations:
          Enum.sort_by(report.violations, &{severity_order(&1.severity), &1.module, &1.rule_id})
    }

    case format do
      :json ->
        IO.puts(Jason.encode!(sorted_report, pretty: true))
        unless report.passed do
          exit({:shutdown, 1})
        end

      :text ->
        print_text_report(sorted_report)
        unless report.passed do
          Mix.shell().error(
            "\n#{report.error_count} error(s), #{report.warning_count} warning(s). Lint failed."
          )

          exit({:shutdown, 1})
        end

        if report.warning_count > 0 do
          Mix.shell().info("\n#{report.warning_count} warning(s). Lint passed with warnings.")
        else
          Mix.shell().info("\nLint passed. No violations.")
        end
    end
  end

  # ---------------------------------------------------------------------------
  # Private
  # ---------------------------------------------------------------------------

  defp parse_format(args) do
    cond do
      "--format=text" in args -> :text
      "--text" in args -> :text
      true -> :json
    end
  end

  defp severity_order(:error), do: 0
  defp severity_order(:warning), do: 1
  defp severity_order(:info), do: 2

  defp print_text_report(report) do
    if report.violations == [] do
      Mix.shell().info("✓ No violations found.")
    else
      report.violations
      |> Enum.each(fn v ->
        icon =
          case v.severity do
            :error -> "✗"
            :warning -> "⚠"
            :info -> "ℹ"
          end

        location = [v.module, v.file_path] |> Enum.reject(&is_nil/1) |> Enum.join(" — ")
        Mix.shell().info("#{icon} [#{v.rule_id}] #{location}\n  #{v.message}\n")
      end)

      Mix.shell().info(
        "#{report.error_count} error(s)  #{report.warning_count} warning(s)  #{report.info_count} info(s)"
      )
    end
  end
end