lib/runbox/runtime/sandbox.ex

defmodule Runbox.Runtime.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 is a scenario-type agnostic version of `Runbox.Runtime.Stage.Sandbox` and
   `Runbox.Runtime.Simple.Sandbox`. This module will identify the type of the scenario and will route
   the request to the appropriate Sandbox implementation.

   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 Mix.Project
  alias Runbox.Message
  alias Runbox.Scenario
  alias Runbox.Scenario.OutputAction
  alias Runbox.Scenario.Type
  alias Runbox.ScenarioRelease.SlaveFunc

  @type topics :: %{topic_name => [Message.t()]}
  @type topic_name :: String.t()
  @type opts :: [opt()]
  @type opt :: {:start_from, non_neg_integer()} | {:modules, [module()]}

  @callback execute_run(topics(), scenario_id :: String.t(), [opt() | {:scenario, Scenario.t()}]) ::
              {:ok, [OutputAction.t()]} | {:error, any()}

  @sandboxes %{
    Type.simple() => Runbox.Runtime.Simple.Sandbox,
    Type.stage() => Runbox.Runtime.Stage.Sandbox
  }

  @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 scenraio'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/).
  """
  @spec execute_run(topics(), scenario_id :: String.t(), opts()) ::
          {:ok, [OutputAction.t()]} | {:error, any()}
  def execute_run(topics, scenario_id, opts \\ []) do
    modules = Keyword.get(opts, :modules) || modules_from_mix_app()

    with {:ok, scenario} <- find_scenario(scenario_id, modules),
         {:ok, module} <- find_sandbox(scenario.manifest.type) do
      module.execute_run(topics, scenario_id, Keyword.put(opts, :scenario, scenario))
    end
  end

  # for internal use between Sandboxes
  @doc false
  def modules_from_mix_app do
    if Code.ensure_loaded?(Project) and function_exported?(Project, :config, 0) do
      case Keyword.fetch(Project.config(), :app) do
        {:ok, app} -> Application.spec(app, :modules)
        :error -> nil
      end
    end
  end

  @doc false
  def find_scenario(scenario_id, modules) do
    case Enum.find(SlaveFunc.release_info(modules), &(&1.manifest.id == scenario_id)) do
      nil -> {:error, :not_found}
      scenario -> {:ok, scenario}
    end
  end

  defp find_sandbox(type) do
    with :error <- Map.fetch(@sandboxes, type) do
      {:error, :sandbox_not_available}
    end
  end
end