lib/mix/tasks/spark_lint.check.ex

defmodule Mix.Tasks.SparkLint.Check do
  @shortdoc "Validate that spark_lint rules are configured (no-op standalone)"

  @moduledoc """
  When run inside a project that has configured SparkLint rules, this task
  provides a standard entry point for running them.

  ## Standalone usage (no rules configured)

      mix spark_lint.check

  Prints a help message explaining how to configure rules.

  ## Usage by rule packages

  Rule packages can depend on `spark_lint` and invoke this task after
  configuring rules via application config:

      # config/config.exs in a project using spark_lint
      config :spark_lint, :rules, [MyApp.LintRules.SomeRule]
      config :spark_lint, :modules_fn, &MyApp.Discovery.all_modules/0

  ## Exit codes

    - `0` — no `:error` violations
    - `1` — one or more `:error` violations
    - `2` — runner crashed
  """

  use Mix.Task

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

    rules = Application.get_env(:spark_lint, :rules, [])
    modules_fn = Application.get_env(:spark_lint, :modules_fn, fn -> [] end)

    if rules == [] do
      Mix.shell().info("""
      spark_lint: no rules configured.

      To use spark_lint.check, add to your config/config.exs:

          config :spark_lint, :rules, [MyApp.LintRules.MyRule]
          config :spark_lint, :modules_fn, &MyApp.Discovery.all_modules/0

      Rules must implement the SparkLint.Rule behaviour.
      """)
    else
      modules = modules_fn.()
      format = parse_format(args)

      {violations, _errors} = SparkLint.run(rules, modules)

      error_count = Enum.count(violations, &(&1.severity == :error))
      warning_count = Enum.count(violations, &(&1.severity == :warning))

      case format do
        :text -> print_text(violations, error_count, warning_count)
        :json -> print_json(violations, error_count, warning_count)
      end

      if error_count > 0 do
        exit({:shutdown, 1})
      end
    end
  rescue
    e ->
      Mix.shell().error("spark_lint.check crashed: #{inspect(e)}")
      exit({:shutdown, 2})
  end

  defp parse_format(args) do
    if "--json" in args or "--format=json" in args, do: :json, else: :text
  end

  defp print_text(violations, error_count, warning_count) do
    Enum.each(violations, fn v ->
      icon =
        case v.severity do
          :error -> "ERROR"
          :warning -> "WARN"
          :info -> "INFO"
        end

      Mix.shell().info("[#{icon}] [#{v.rule}] #{inspect(v.module)}: #{v.message}")
    end)

    Mix.shell().info("\n#{error_count} error(s), #{warning_count} warning(s).")
  end

  defp print_json(violations, error_count, _warning_count) do
    output = %{
      passed: error_count == 0,
      violations:
        Enum.map(violations, fn v ->
          %{rule: v.rule, module: inspect(v.module), severity: v.severity, message: v.message}
        end)
    }

    # Use OTP 27+ :json stdlib for zero deps
    case :json.encode(output) do
      {:ok, json} -> IO.puts(json)
      {:error, _reason} -> IO.write(inspect(output))
    end
  end
end