Packages

A stateful property-based testing framework for Elixir

Current section

Files

Jump to
property_damage lib mix tasks pd.gen.command.ex
Raw

lib/mix/tasks/pd.gen.command.ex

defmodule Mix.Tasks.Pd.Gen.Command do
@moduledoc """
Generate a PropertyDamage command module.
## Usage
mix pd.gen.command MyApp.Commands.CreateUser
## Options
--semantics SEM Command semantics (sync, probe, async)
--fields FIELDS Comma-separated field names
## Server-generated values
Commands no longer declare the refs they create. Instead, mark
server-generated fields with `external()` in your event struct definitions:
defmodule MyApp.Events.UserCreated do
import PropertyDamage, only: [external: 0]
defstruct [:name, :email, id: external()] # id is server-generated
end
See `PropertyDamage.external/0` for full documentation.
## Examples
# Basic command
mix pd.gen.command MyApp.Commands.CreateUser
# Probe command with fields
mix pd.gen.command MyApp.Commands.GetUser --semantics probe --fields user_ref
# Command with multiple fields
mix pd.gen.command MyApp.Commands.UpdateUser --fields user_ref,name,email
"""
use Mix.Task
@shortdoc "Generate a PropertyDamage command module"
@impl true
def run(args) do
{opts, argv, _} =
OptionParser.parse(args,
strict: [semantics: :string, fields: :string]
)
case argv do
[module_name] ->
generate_command(module_name, opts)
[] ->
Mix.shell().error("Usage: mix pd.gen.command MODULE_NAME [OPTIONS]")
Mix.shell().error("Run `mix help pd.gen.command` for more information.")
_ ->
Mix.shell().error("Expected exactly one module name")
end
end
defp generate_command(module_name, opts) do
semantics = Keyword.get(opts, :semantics, "sync")
fields = parse_fields(Keyword.get(opts, :fields, ""))
# Parse module name to get path
path = module_to_path(module_name)
dir = Path.dirname(path)
# Ensure directory exists
File.mkdir_p!(dir)
# Generate content
content = generate_content(module_name, fields, semantics)
# Write file
File.write!(path, content)
Mix.shell().info("Generated #{path}")
Mix.shell().info("")
Mix.shell().info("Next steps:")
Mix.shell().info(" 1. Fill in the field generators in generator/1")
Mix.shell().info(" 2. Add to your model's commands/0 (use when: for preconditions)")
end
defp parse_fields(""), do: []
defp parse_fields(fields), do: String.split(fields, ",") |> Enum.map(&String.trim/1)
defp module_to_path(module_name) do
path =
module_name
|> String.split(".")
|> Enum.map_join("/", &Macro.underscore/1)
"lib/#{path}.ex"
end
defp generate_content(module_name, fields, semantics) do
fields_atoms = Enum.map(fields, &String.to_atom/1)
defstruct_line = if fields == [], do: "[]", else: inspect(fields_atoms)
semantics_function =
case semantics do
"sync" -> ""
other -> "\n @impl true\n def semantics, do: :#{other}\n"
end
generator_body = generate_generator_body(fields)
# With no fields the body ignores overrides; underscore it to stay warning-free.
overrides_param = if fields == [], do: "_overrides", else: "overrides"
"""
defmodule #{module_name} do
@moduledoc \"\"\"
TODO: Add description for this command.
Preconditions (when this command may run) belong in the model's
`when:` option, not here.
\"\"\"
@behaviour PropertyDamage.Command
defstruct #{defstruct_line}
@impl true
def generator(#{overrides_param} \\\\ %{}) do
# Returns a StreamData generator of field maps; the framework maps each
# to a %#{module_name}{} struct.
#{generator_body}
end
#{semantics_function}end
"""
end
defp generate_generator_body([]) do
"StreamData.constant(%{})"
end
defp generate_generator_body(fields) do
field_generators =
fields
|> Enum.map_join(",\n ", fn field ->
if String.ends_with?(field, "_ref") do
"#{field}: StreamData.constant(:TODO_pick_from_state)"
else
"#{field}: StreamData.constant(:TODO)"
end
end)
"""
%{
#{field_generators}
}
|> PropertyDamage.Generator.merge_overrides(overrides)
|> StreamData.fixed_map()
"""
end
end