Current section
Files
Jump to
Current section
Files
lib/mix/tasks/pd.validate.ex
defmodule Mix.Tasks.Pd.Validate do
@moduledoc """
Validates a PropertyDamage model and adapter configuration.
This task checks that your model is correctly configured before running tests,
catching common mistakes early.
## Usage
mix pd.validate MyApp.TestModel MyApp.TestAdapter
## What Gets Checked
### Errors (validation fails)
- Model module exists and implements required callbacks
- Adapter module exists and implements required callbacks
- All command modules exist and implement required callbacks
- All projection modules exist
- Injectable events are covered by injector adapters
### Warnings (validation passes with warnings)
- Commands missing `downstream_observables/0`
- Events produced but not handled by assertion projections
- Missing optional callbacks that may be useful
## Options
--verbose Show detailed information about the model
--strict Treat warnings as errors
## Examples
# Basic validation
mix pd.validate MyApp.TestModel MyApp.TestAdapter
# With verbose output
mix pd.validate MyApp.TestModel MyApp.TestAdapter --verbose
# Fail on warnings
mix pd.validate MyApp.TestModel MyApp.TestAdapter --strict
"""
use Mix.Task
@shortdoc "Validate PropertyDamage model configuration"
@impl true
def run(args) do
# Parse arguments
{opts, argv, _} = OptionParser.parse(args, strict: [verbose: :boolean, strict: :boolean])
verbose = Keyword.get(opts, :verbose, false)
strict = Keyword.get(opts, :strict, false)
case argv do
[model_str, adapter_str] ->
# Ensure code is compiled
Mix.Task.run("compile", [])
# Convert strings to modules
model = parse_module(model_str)
adapter = parse_module(adapter_str)
validate_and_report(model, adapter, verbose, strict)
[model_str] ->
# Only model provided - show helpful message
Mix.Task.run("compile", [])
model = parse_module(model_str)
IO.puts("\n")
print_color(:yellow, "Note: No adapter specified. Validating model only.\n")
validate_model_only(model, verbose, strict)
[] ->
print_usage()
_ ->
print_color(:red, "Error: Expected 1 or 2 arguments (model and optional adapter)\n")
print_usage()
System.halt(1)
end
end
defp parse_module(string) do
string
|> String.replace(~r/^Elixir\./, "")
|> then(&("Elixir." <> &1))
|> String.to_atom()
end
defp validate_and_report(model, adapter, verbose, strict) do
IO.puts("\n")
print_header("PropertyDamage Validation")
IO.puts("")
# Check if modules exist
model_exists = Code.ensure_loaded?(model)
adapter_exists = Code.ensure_loaded?(adapter)
unless model_exists do
print_color(:red, "ERROR: Model module #{inspect(model)} does not exist\n")
print_hint("Make sure the module is defined and the project is compiled.")
System.halt(1)
end
unless adapter_exists do
print_color(:red, "ERROR: Adapter module #{inspect(adapter)} does not exist\n")
print_hint("Make sure the module is defined and the project is compiled.")
System.halt(1)
end
# Run validation
case PropertyDamage.Validation.validate!(model, adapter) do
{:ok, warnings} ->
if verbose do
PropertyDamage.Validation.print_summary(model, adapter, warnings)
end
print_validation_results(model, adapter, warnings, verbose)
if strict and length(warnings) > 0 do
IO.puts("")
print_color(:red, "FAILED: #{length(warnings)} warning(s) in strict mode\n")
System.halt(1)
else
print_color(:green, "\nVALIDATION PASSED\n")
end
end
rescue
e in ArgumentError ->
IO.puts("")
print_color(:red, "VALIDATION FAILED\n")
IO.puts("")
print_errors_with_hints(e.message)
System.halt(1)
end
defp validate_model_only(model, verbose, strict) do
unless Code.ensure_loaded?(model) do
print_color(:red, "ERROR: Model module #{inspect(model)} does not exist\n")
System.halt(1)
end
errors = []
warnings = []
# Check model callbacks (extra_projections is optional)
required_callbacks = [:commands, :state_projection]
errors =
for callback <- required_callbacks,
not function_exported?(model, callback, 0),
reduce: errors do
acc -> ["Model missing required callback #{callback}/0" | acc]
end
unless Enum.empty?(errors) do
print_color(:red, "ERRORS:\n")
for error <- errors do
IO.puts(" - #{error}")
end
System.halt(1)
end
# Check commands
commands = model.commands() |> PropertyDamage.Model.normalize_commands()
errors =
for {_weight, cmd} <- commands, not Code.ensure_loaded?(cmd), reduce: errors do
acc -> ["Command module #{inspect(cmd)} does not exist" | acc]
end
# Check command callbacks
errors =
for {_weight, cmd} <- commands, Code.ensure_loaded?(cmd), reduce: errors do
acc ->
acc =
if function_exported?(cmd, :precondition, 1),
do: acc,
else: ["Command #{inspect(cmd)} missing precondition/1" | acc]
if function_exported?(cmd, :new!, 2),
do: acc,
else: ["Command #{inspect(cmd)} missing new!/2" | acc]
end
# Check projections
state_proj = model.state_projection()
errors =
if Code.ensure_loaded?(state_proj) do
errors
else
["State projection #{inspect(state_proj)} does not exist" | errors]
end
extra_projs =
if function_exported?(model, :extra_projections, 0) do
model.extra_projections()
else
[]
end
errors =
for proj <- extra_projs, not Code.ensure_loaded?(proj), reduce: errors do
acc -> ["Extra projection #{inspect(proj)} does not exist" | acc]
end
# Collect warnings
warnings =
for {_weight, cmd} <- commands,
Code.ensure_loaded?(cmd),
not function_exported?(cmd, :downstream_observables, 0),
reduce: warnings do
acc ->
[
"Command #{cmd |> Module.split() |> List.last()} missing downstream_observables/0"
| acc
]
end
if verbose do
print_model_summary(model, commands)
end
if Enum.empty?(errors) do
if length(warnings) > 0 do
print_color(:yellow, "\nWARNINGS:\n")
for warning <- warnings do
IO.puts(" - #{warning}")
end
end
if strict and length(warnings) > 0 do
print_color(:red, "\nFAILED: #{length(warnings)} warning(s) in strict mode\n")
System.halt(1)
else
print_color(:green, "\nMODEL VALIDATION PASSED\n")
end
else
print_color(:red, "\nERRORS:\n")
for error <- errors do
IO.puts(" - #{error}")
end
System.halt(1)
end
end
defp print_validation_results(model, adapter, warnings, _verbose) do
commands = model.commands() |> PropertyDamage.Model.normalize_commands()
extra_projs =
if function_exported?(model, :extra_projections, 0) do
model.extra_projections()
else
[]
end
IO.puts("Model: #{inspect(model)}")
IO.puts("Adapter: #{inspect(adapter)}")
IO.puts("Commands: #{length(commands)}")
IO.puts("Extra: #{length(extra_projs)}")
if length(warnings) > 0 do
IO.puts("")
print_color(:yellow, "WARNINGS (#{length(warnings)}):\n")
for warning <- warnings do
IO.puts(" - #{warning}")
end
end
end
defp print_model_summary(model, commands) do
state_proj = model.state_projection()
extra_projs =
if function_exported?(model, :extra_projections, 0) do
model.extra_projections()
else
[]
end
IO.puts("Model: #{inspect(model)}")
IO.puts("")
IO.puts("Commands (#{length(commands)}):")
for {weight, cmd} <- commands do
name = cmd |> Module.split() |> List.last()
IO.puts(" #{String.pad_trailing(name, 25)} weight: #{weight}")
end
IO.puts("")
IO.puts("State Projection: #{inspect(state_proj)}")
IO.puts("Extra Projections: #{length(extra_projs)}")
for proj <- extra_projs do
name = proj |> Module.split() |> List.last()
IO.puts(" - #{name}")
end
end
defp print_errors_with_hints(message) do
# Parse the error message and provide hints
lines = String.split(message, "\n")
for line <- lines do
cond do
String.contains?(line, "does not exist") ->
IO.puts(line)
print_hint("Check the module name spelling and ensure the code is compiled.")
String.contains?(line, "missing required callback") ->
IO.puts(line)
print_hint("Add the missing callback to implement the behaviour.")
String.contains?(line, "not covered by any InjectorAdapter") ->
IO.puts(line)
print_hint("Add the event to an injector adapter's @emits list.")
true ->
IO.puts(line)
end
end
end
defp print_hint(text) do
print_color(:cyan, " Hint: #{text}\n")
end
defp print_header(text) do
border = String.duplicate("=", String.length(text) + 4)
IO.puts(border)
IO.puts(" #{text}")
IO.puts(border)
end
defp print_color(color, text) do
IO.puts([color_code(color), text, IO.ANSI.reset()])
end
defp color_code(:red), do: IO.ANSI.red()
defp color_code(:green), do: IO.ANSI.green()
defp color_code(:yellow), do: IO.ANSI.yellow()
defp color_code(:cyan), do: IO.ANSI.cyan()
defp print_usage do
IO.puts("""
Usage: mix pd.validate MODEL [ADAPTER] [OPTIONS]
Arguments:
MODEL The model module (e.g., MyApp.TestModel)
ADAPTER The adapter module (e.g., MyApp.HTTPAdapter) [optional]
Options:
--verbose Show detailed information about the configuration
--strict Treat warnings as errors (exit code 1)
Examples:
mix pd.validate MyApp.TestModel MyApp.HTTPAdapter
mix pd.validate MyApp.TestModel MyApp.HTTPAdapter --verbose
mix pd.validate MyApp.TestModel --strict
""")
end
end