defmodule Mix.Tasks.Raxol.PreCommit do @moduledoc """ Run all pre-commit checks for the Raxol project. ## Usage mix raxol.pre_commit [options] ## Options * `--parallel` - Run checks in parallel (default: true) * `--fail-fast` - Stop on first failure (default: false) * `--only` - Run only specific checks (comma-separated) * `--skip` - Skip specific checks (comma-separated) * `--quiet` - Minimal output * `--verbose` - Detailed output with timing and cache info * `--fix` - Auto-fix issues when possible (currently: format) * `--no-cache` - Disable cache, run all checks fresh ## Examples # Run all checks mix raxol.pre_commit # Run with auto-fix mix raxol.pre_commit --fix # Force fresh check without cache mix raxol.pre_commit --no-cache # Run only format and compile checks mix raxol.pre_commit --only format,compile # Skip docs check mix raxol.pre_commit --skip docs # Sequential execution with fail-fast mix raxol.pre_commit --no-parallel --fail-fast # Verbose output with cache statistics mix raxol.pre_commit --verbose """ use Mix.Task alias Raxol.PreCommit.{Config, Progress, ErrorFormatter, Metrics} @shortdoc "Run all pre-commit checks" @check_modules %{ format: Mix.Tasks.Raxol.Check.Format, compile: Mix.Tasks.Raxol.Check.Compile, credo: Mix.Tasks.Raxol.Check.Credo, tests: Mix.Tasks.Raxol.Check.Tests, docs: Mix.Tasks.Raxol.Check.Docs, dialyzer: Mix.Tasks.Raxol.Check.Dialyzer, security: Mix.Tasks.Raxol.Check.Security } @impl Mix.Task def run(args) do {opts, _, _} = OptionParser.parse(args, switches: [ parallel: :boolean, fail_fast: :boolean, only: :string, skip: :string, quiet: :boolean, verbose: :boolean, fix: :boolean, no_cache: :boolean ], aliases: [ p: :parallel, f: :fail_fast, q: :quiet, v: :verbose ] ) # Load configuration from all sources config = Config.load(build_cli_config(opts)) unless config.quiet do IO.puts("\nšŸš€ Running Raxol Pre-commit Checks\n") end start_time = System.monotonic_time(:millisecond) # Start progress indicator if enabled maybe_start_progress(config) checks = determine_checks(config) # Initialize all checks in progress display maybe_init_checks(checks, config) results = run_checks(checks, config) # Stop progress indicator maybe_stop_progress(config) elapsed = System.monotonic_time(:millisecond) - start_time # Record metrics unless disabled unless Map.get(config, :skip_metrics, false) do Metrics.record_run(results, elapsed, config) end print_summary(results, elapsed, config) exit_code = if all_passed?(results), do: 0, else: 1 System.halt(exit_code) end defp build_cli_config(opts) do # Build config map from CLI options cli_config = %{} cli_config = maybe_put(cli_config, :parallel, Keyword.get(opts, :parallel)) cli_config = maybe_put(cli_config, :fail_fast, Keyword.get(opts, :fail_fast)) cli_config = maybe_put(cli_config, :quiet, Keyword.get(opts, :quiet)) cli_config = maybe_put(cli_config, :verbose, Keyword.get(opts, :verbose)) cli_config = maybe_put(cli_config, :auto_fix, Keyword.get(opts, :fix)) cli_config = maybe_put(cli_config, :no_cache, Keyword.get(opts, :no_cache)) # Handle special cases cli_config = case Keyword.get(opts, :only) do nil -> cli_config only_str -> Map.put(cli_config, :checks, parse_check_list(only_str)) end cli_config = case Keyword.get(opts, :skip) do nil -> cli_config skip_str -> Map.put(cli_config, :skip, parse_check_list(skip_str)) end cli_config end defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) defp parse_check_list(nil), do: [] defp parse_check_list(str) do str |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.map(&String.to_atom/1) end defp determine_checks(config) do # Get checks from config (defaults, file config, or CLI override) base_checks = Map.get(config, :checks, [:format, :compile, :credo, :tests, :docs]) # Apply skip filter if present skip_list = Map.get(config, :skip, []) checks = case skip_list do [] -> base_checks skips -> Enum.reject(base_checks, &(&1 in skips)) end checks |> Enum.map(&{&1, Map.get(@check_modules, &1)}) |> Enum.filter(fn {_, module} -> module != nil end) end defp run_checks(checks, %{parallel: true, fail_fast: false} = config) do # Smart parallel execution with CPU core detection max_concurrency = determine_concurrency(checks) checks |> Task.async_stream( fn {name, module} -> maybe_start_check(name, config) check_config = Config.get_check_config(config, name) result = run_single_check(module, check_config) maybe_update_check_status(name, result, config) {name, result} end, ordered: true, timeout: 30_000, max_concurrency: max_concurrency ) |> Enum.map(fn {:ok, result} -> result end) end defp run_checks(checks, %{parallel: false} = config) do Enum.reduce_while(checks, [], fn {name, module}, acc -> maybe_start_check(name, config) check_config = Config.get_check_config(config, name) result = run_single_check(module, check_config) maybe_update_check_status(name, result, config) new_acc = [{name, result} | acc] if config.fail_fast and result.status == :error do {:halt, Enum.reverse(new_acc)} else {:cont, new_acc} end end) |> Enum.reverse() end defp run_checks(checks, config) do # Parallel with fail-fast (more complex, simplified for now) run_checks(checks, %{config | parallel: false}) end defp determine_concurrency(checks) do # Get CPU cores available cpu_cores = System.schedulers_online() # Determine check dependencies # compile must run before tests (tests might need compiled code) has_compile = Enum.any?(checks, fn {name, _} -> name == :compile end) has_tests = Enum.any?(checks, fn {name, _} -> name == :tests end) if has_compile and has_tests do # If we have dependent checks, limit concurrency min(2, cpu_cores) else # Otherwise use available cores, but cap at check count min(length(checks), cpu_cores) end end defp run_single_check(module, config) do start_time = System.monotonic_time(:millisecond) result = try do # Ensure module is compiled and loaded case Code.ensure_compiled(module) do {:module, _} -> if function_exported?(module, :run, 1) do module.run(config) else {:error, "Check module #{inspect(module)} not implemented"} end {:error, _} -> # Try loading all Mix tasks Mix.Task.load_all() # Retry case Code.ensure_compiled(module) do {:module, _} -> if function_exported?(module, :run, 1) do module.run(config) else {:error, "Check module #{inspect(module)} not implemented"} end {:error, reason} -> {:error, "Module #{inspect(module)} not found: #{inspect(reason)}"} end end rescue e -> {:error, Exception.format(:error, e, __STACKTRACE__)} catch :exit, reason -> {:error, "Check exited: #{inspect(reason)}"} end elapsed = System.monotonic_time(:millisecond) - start_time case result do {:ok, details} -> %{status: :ok, details: details, elapsed: elapsed} {:error, reason} -> %{status: :error, reason: reason, elapsed: elapsed} {:warning, reason} -> %{status: :warning, reason: reason, elapsed: elapsed} end end defp all_passed?(results) do Enum.all?(results, fn {_, result} -> result.status in [:ok, :warning] end) end defp print_summary(results, total_elapsed, config) do unless config.quiet do IO.puts("\n" <> String.duplicate("─", 50)) IO.puts("šŸ“Š Pre-commit Check Summary\n") Enum.each(results, fn {name, result} -> icon = case result.status do :ok -> "āœ…" :warning -> "āš ļø " :error -> "āŒ" end time_str = if config.verbose do " (#{result.elapsed}ms)" else "" end check_name = name |> to_string() |> String.capitalize() IO.puts(" #{icon} #{check_name}#{time_str}") # Show detailed error information for failures case {result.status, result[:reason], config.verbose} do {:error, reason, true} when is_map(reason) -> ErrorFormatter.print_error(name, reason, verbose: true, color: true) {:error, reason, _} when is_binary(reason) -> IO.puts(" #{reason}") {:warning, reason, _} when is_binary(reason) -> IO.puts(" #{reason}") _ -> :ok end end) IO.puts("\n" <> String.duplicate("─", 50)) passed = Enum.count(results, fn {_, r} -> r.status == :ok end) warnings = Enum.count(results, fn {_, r} -> r.status == :warning end) failed = Enum.count(results, fn {_, r} -> r.status == :error end) total = length(results) IO.puts( "Total: #{total} checks, #{passed} passed, #{warnings} warnings, #{failed} failed" ) IO.puts("Time: #{format_time(total_elapsed)}\n") case all_passed?(results) do true -> IO.puts("✨ All checks passed! Ready to commit.") false -> IO.puts("āŒ Pre-commit checks failed. Please fix the issues above.") # Show error summary if not verbose (verbose shows full errors inline) unless config.verbose do ErrorFormatter.print_summary(results, color: true) end end end end defp format_time(ms) when ms < 1000, do: "#{ms}ms" defp format_time(ms), do: "#{Float.round(ms / 1000, 1)}s" # Progress indicator helpers defp maybe_start_progress(config) do case Progress.enabled?(config) do true -> {:ok, _} = Progress.start_link( verbose: Map.get(config, :verbose, false), parallel: Map.get(config, :parallel, true) ) false -> :ok end end defp maybe_stop_progress(config) do case Progress.enabled?(config) do true -> Progress.stop() false -> :ok end catch :exit, _ -> :ok end defp maybe_init_checks(checks, config) do case Progress.enabled?(config) do true -> Enum.each(checks, fn {name, _module} -> Progress.init_check(name) end) false -> :ok end end defp maybe_start_check(name, config) do case Progress.enabled?(config) do true -> Progress.start_check(name) false -> :ok end catch :exit, _ -> :ok end defp maybe_update_check_status(name, result, config) do case Progress.enabled?(config) do true -> case result.status do :ok -> Progress.complete_check(name, result[:elapsed]) :warning -> Progress.warn_check(name, result[:reason]) :error -> Progress.fail_check(name, result[:reason]) end false -> :ok end catch :exit, _ -> :ok end end