Current section
Files
Jump to
Current section
Files
lib/mix/tasks/pd.gen.model.ex
defmodule Mix.Tasks.Pd.Gen.Model do
@moduledoc """
Generate a PropertyDamage model module.
## Usage
mix pd.gen.model MyApp.TestModel
## Options
--commands COMMANDS Comma-separated list of command module names
--projection NAME State projection module name
--assertion-projections NAMES Comma-separated list of assertion projection names
## Examples
# Basic model
mix pd.gen.model MyApp.TestModel
# Model with commands specified
mix pd.gen.model MyApp.TestModel --commands CreateUser,UpdateUser,DeleteUser
# Full specification
mix pd.gen.model MyApp.TestModel \\
--commands CreateUser,UpdateUser,DeleteUser \\
--projection MyApp.Projections.ModelState \\
--assertion-projections BalanceChecker,AuditLog
## Projections
All projections use `PropertyDamage.Model.Projection` and can:
- Track state via `apply/2`
- Define synchronous assertions via `@trigger`
- Define temporal assertions via `@poll_state`
The `command_sequence_projection` is the primary projection used for command generation.
The `assertion_projections` are additional projections for invariants and side tracking.
"""
use Mix.Task
@shortdoc "Generate a PropertyDamage model module"
@impl true
def run(args) do
{opts, argv, _} =
OptionParser.parse(args,
strict: [commands: :string, projection: :string, assertion_projections: :string]
)
case argv do
[module_name] ->
generate_model(module_name, opts)
[] ->
Mix.shell().error("Usage: mix pd.gen.model MODULE_NAME [OPTIONS]")
Mix.shell().error("Run `mix help pd.gen.model` for more information.")
_ ->
Mix.shell().error("Expected exactly one module name")
end
end
defp generate_model(module_name, opts) do
commands = parse_list(Keyword.get(opts, :commands, ""))
projection = Keyword.get(opts, :projection)
assertion_projections = parse_list(Keyword.get(opts, :assertion_projections, ""))
path = module_to_path(module_name)
dir = Path.dirname(path)
File.mkdir_p!(dir)
content = generate_content(module_name, commands, projection, assertion_projections)
File.write!(path, content)
Mix.shell().info("Generated #{path}")
Mix.shell().info("")
Mix.shell().info("Next steps:")
Mix.shell().info(" 1. Create command modules listed in commands/0")
Mix.shell().info(" 2. Create projection modules (use: mix pd.gen.projection)")
Mix.shell().info(" 3. Create an adapter module")
Mix.shell().info(" 4. Run: PropertyDamage.run(model: #{module_name}, adapter: YourAdapter)")
end
defp parse_list(""), do: []
defp parse_list(str), do: String.split(str, ",") |> 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, commands, projection, assertion_projections) do
# Infer namespace from module name
parts = String.split(module_name, ".")
namespace = Enum.slice(parts, 0..-2//1) |> Enum.join(".")
commands_section = generate_commands_section(commands, namespace)
projection_section = generate_projection_section(projection, namespace)
assertion_projections_section =
generate_assertion_projections_section(assertion_projections, namespace)
"""
defmodule #{module_name} do
@moduledoc \"\"\"
PropertyDamage model for testing.
TODO: Add description of what this model tests.
## Usage
PropertyDamage.run(
model: #{module_name},
adapter: #{namespace}.Adapter,
max_runs: 100
)
\"\"\"
@behaviour PropertyDamage.Model
#{commands_section}
@impl true
def commands do
[
# TODO: Adjust weights based on desired command frequency
# Higher weight = more frequent generation
#{generate_commands_list(commands)}
]
end
@impl true
def command_sequence_projection do
#{projection_section}
end
@impl true
def assertion_projections do
[
#{assertion_projections_section}
]
end
# Optional: Uncomment to enable lifecycle hooks
#
# def setup_once(config) do
# # One-time setup (not repeated during shrinking)
# :ok
# end
#
# def setup_each(config) do
# # Reset state before each test run
# :ok
# end
#
# def teardown_each(config) do
# :ok
# end
#
# def teardown_once(config) do
# :ok
# end
#
# def terminate?(state, command, events) do
# # Return true to stop command generation
# false
# end
end
"""
end
defp generate_commands_section([], namespace) do
"""
alias #{namespace}.Commands.{
# TODO: Add your command modules here
# CreateEntity,
# UpdateEntity,
# DeleteEntity
}
"""
end
defp generate_commands_section(commands, namespace) do
command_list = Enum.join(commands, ",\n ")
"""
alias #{namespace}.Commands.{
#{command_list}
}
"""
end
defp generate_commands_list([]) do
"""
# {CreateEntity, weight: 3}, # High weight - frequent
# {UpdateEntity, weight: 2}, # Medium weight
# {DeleteEntity, weight: 1} # Low weight - rare
"""
end
defp generate_commands_list(commands) do
commands
|> Enum.with_index(1)
|> Enum.map_join(",\n ", fn {cmd, idx} ->
# Give decreasing weights
weight = max(1, 4 - idx)
"{#{weight}, #{cmd}}"
end)
end
defp generate_projection_section(nil, namespace) do
"#{namespace}.Projections.ModelState"
end
defp generate_projection_section(projection, _namespace) do
projection
end
defp generate_assertion_projections_section([], namespace) do
"# #{namespace}.Projections.SomeExtraProjection"
end
defp generate_assertion_projections_section(assertion_projections, _namespace) do
Enum.join(assertion_projections, ",\n ")
end
end