lib/runbox/runtime/simple/sandbox.ex

defmodule Runbox.Runtime.Simple.Sandbox do
  @moduledoc group: :dev_tools
  @moduledoc """
  Sandbox is a helper for executing runs without started Altworx application.

  Sandbox can be used for scenarios unit testing and also for interactive development, e.g. in
  LiveBook.

  This module is only for Simple scenarios.

  For interactive development of scenarios, run `iex --erl "-runbox mode slave" -S mix` from command
  line in your scenarios application root directory and then use Sandbox functions to execute your
  runs (of course use `recompile` before run execution if you changed scenario code).
  """

  alias Runbox.Message
  alias Runbox.RunStartContext
  alias Runbox.Runtime.OutputAction, as: RuntimeOutputAction
  alias Runbox.Runtime.Sandbox
  alias Runbox.Runtime.Simple.TemplateCarrier
  alias Runbox.Scenario.OutputAction
  alias Runbox.Scenario.Simple
  alias Runbox.StateStore.Entity

  @behaviour Sandbox

  @default_start_from 0

  @doc """
  Starts a new run for `scenario_id` and processes all input messages.

  Returns all output actions generated by run execution. Input messages for each runtime topic,
  referenced by its logical name, are passed in the `topics` argument. Undefined topics are
  considered empty.

  There are few options that can be specified.

  - `:start_from` - specify the start from value for the run. The value is used to filter out
    messages and is passed to the scenario's `init` callback.
  - `:modules` - list of modules containing the scenario (manifest and template, which can be just
    one module). If not specified, modules are auto-discovered from the current Mix app. This opens
    up the possibility to write ad-hoc scenarios with only Runbox as dependency, e.g. in
    [Livebook](https://livebook.dev/).
  """
  @impl Sandbox
  @spec execute_run(Sandbox.topics(), scenario_id :: String.t(), Sandbox.opts()) ::
          {:ok, [OutputAction.t()]} | {:error, any()}
  def execute_run(topics, scenario_id, opts \\ []) do
    start_from = Keyword.get(opts, :start_from, @default_start_from)

    with {:ok, scenario} <- get_scenario(scenario_id, opts),
         # Simple scenarios always have single template
         [template] <- scenario.templates,
         {:ok, input_topics} <- Simple.input_topics(template.module),
         :ok <- maybe_execute_on_start(scenario),
         {:ok, state, init_output_actions} <- init(scenario, template, start_from),
         messages = prepare_messages(topics, input_topics, start_from),
         {:ok, message_output_actions} <- handle_messages(messages, state) do
      {:ok, process_outputs(init_output_actions ++ message_output_actions)}
    end
  end

  defp get_scenario(id, opts) do
    scenario = Keyword.get(opts, :scenario)

    if scenario do
      {:ok, scenario}
    else
      modules = Keyword.get(opts, :modules) || Sandbox.modules_from_mix_app()
      Sandbox.find_scenario(id, modules)
    end
  end

  # Init the scenario - simulate the start of the template carrier, including executing the init
  # phase.
  defp init(scenario, template, start_from) do
    run_id = UUID.uuid1()

    args = %{
      run_id: run_id,
      config: %{
        module: template.module,
        subscribe_to: [:fake_sub],
        scenario_id: scenario.manifest.id,
        start_from: start_from,
        start_or_continue: :start
      },
      state_entity: Entity.new(run_id, :template, :none, start_from, nil)
    }

    {:producer_consumer, state, _} =
      TemplateCarrier.init({args, nil, %RunStartContext{components_pids: %{fake_sub: self()}}})

    receive do
      :init_state -> :ok
    end

    init_template(state)
  end

  # Execute the initialize phase of the template.
  defp init_template(state) do
    {:noreply, outputs, state} = TemplateCarrier.handle_info(:init_state, state)
    {:ok, state, outputs}
  end

  # Prepare messages to be handled later. This includes converting it from the sandbox format into
  # just a list of messages, filtering out messages we are not interested in etc.
  defp prepare_messages(messages_in_topics, input_topics, start_from) do
    consumed_topics =
      Map.new(input_topics, fn
        {topic, type} -> {topic, type}
        topic -> {topic, :input}
      end)

    messages_in_topics
    |> Enum.filter(fn {topic, _} -> Map.has_key?(consumed_topics, topic) end)
    |> Enum.flat_map(fn {topic, messages} ->
      Enum.map(messages, fn %Message{} = msg -> %{msg | from: topic} end)
    end)
    |> Enum.filter(&(consumed_topics[&1.from] == :load || &1.timestamp >= start_from))
    |> Enum.sort_by(fn %Message{timestamp: time} -> time end)
  end

  defp handle_messages(messages, state) do
    {:noreply, outputs, _state} = TemplateCarrier.handle_events(messages, nil, state)
    {:ok, outputs}
  end

  # Convert outputs to the final format and remove non-OA outputs.
  defp process_outputs(outputs) do
    Enum.flat_map(outputs, fn
      %RuntimeOutputAction{} = oa ->
        [
          %OutputAction{
            body: oa.body,
            timestamp: oa.timestamp
          }
        ]

      # other non-OA stuff the TemplateCarrier might produce, like ticks
      _other ->
        []
    end)
  end

  defp maybe_execute_on_start(scenario) do
    case scenario.opts[:on_start] do
      {m, f, a} -> apply(m, f, a)
      nil -> :ok
    end
  end
end