lib/foundry/test_scenario.ex

defmodule Foundry.TestScenario do
  @moduledoc """
  Lightweight test-side annotations for Studio scenario extraction.

  `@scenario` is intentionally small. Real executable test calls remain the
  primary source of truth; the attribute only adds labels or exact-focus hints
  where code alone is ambiguous.

  `capture/2` automatically records executable entrypoints inside the wrapped
  test body. `trace_node/2` remains available as a compatibility escape hatch
  for flows that cannot yet be inferred automatically.

  Example:

      use Foundry.TestScenario

      @scenario category: :compliance,
                compliance_links: ["RG-UK-014"],
                flow: [
                  %{
                    id: "receive",
                    type: :entry,
                    node: "Finance.WithdrawalWebhook",
                    label: "Validate webhook payload",
                    action: "handle_webhook",
                    focus_targets: ["Finance.WithdrawalWebhookEvent"]
                  }
                ]
  """

  alias Foundry.Context.Scenarios.CallClassifier
  alias Foundry.TestScenario.RuntimeCapture

  defmacro __using__(_opts) do
    quote do
      Module.register_attribute(__MODULE__, :scenario, accumulate: true, persist: true)
      import Foundry.TestScenario, only: [capture: 1, capture: 2]
    end
  end

  defmacro capture(do: body) do
    caller = __CALLER__
    describe_name = current_describe_name(caller)
    test_name = current_test_name(caller, describe_name)

    context = %{
      module: caller.module,
      describe: describe_name,
      test: test_name,
      file: caller.file,
      line: caller.line
    }

    instrumented_body = instrument_capture_body(body, caller)

    quote do
      Foundry.TestScenario.__capture__(unquote(Macro.escape(context)), fn ->
        unquote(instrumented_body)
      end)
    end
  end

  defmacro capture(context, fun_ast) do
    case fun_ast do
      {:fn, _meta, [{:->, _arrow_meta, [[], body]}]} ->
        instrumented_body = instrument_capture_body(body, __CALLER__)

        quote do
          Foundry.TestScenario.__capture__(unquote(context), fn ->
            unquote(instrumented_body)
          end)
        end

      _ ->
        quote do
          Foundry.TestScenario.__capture__(unquote(context), unquote(fun_ast))
        end
    end
  end

  def __capture__(context, fun) when is_map(context) and is_function(fun, 0) do
    RuntimeCapture.capture(context, fun)
  end

  @doc false
  def trace_call(attrs, fun) when is_map(attrs) and is_function(fun, 0) do
    RuntimeCapture.trace_call(attrs, fun)
  end

  defp current_describe_name(%Macro.Env{} = caller) do
    case Module.get_attribute(caller.module, :ex_unit_describe) do
      {_line, name, _level} -> name
      _ -> nil
    end
  end

  defp current_test_name(%Macro.Env{function: {fun_name, _arity}}, describe_name) do
    fun_name
    |> Atom.to_string()
    |> String.replace_prefix("test ", "")
    |> String.replace_prefix("property ", "")
    |> strip_describe_prefix(describe_name)
  end

  defp instrument_capture_body(body, caller) do
    Macro.prewalk(body, fn
      {:|>, _meta, [left, {{:., _, [module_ast, fun]}, _call_meta, args}]} = pipe_ast ->
        case CallClassifier.runtime_trace_attrs(
               module_ast,
               fun,
               [left | args || []],
               pipe_ast,
               caller
             ) do
          nil ->
            pipe_ast

          attrs ->
            quote do
              Foundry.TestScenario.trace_call(unquote(Macro.escape(attrs)), fn ->
                unquote(pipe_ast)
              end)
            end
        end

      {{:., _meta, [module_ast, fun]}, _call_meta, args} = call_ast ->
        case CallClassifier.runtime_trace_attrs(module_ast, fun, args || [], call_ast, caller) do
          nil ->
            call_ast

          attrs ->
            quote do
              Foundry.TestScenario.trace_call(unquote(Macro.escape(attrs)), fn ->
                unquote(call_ast)
              end)
            end
        end

      node ->
        node
    end)
  end

  defp strip_describe_prefix(test_name, nil), do: test_name
  defp strip_describe_prefix(test_name, ""), do: test_name

  defp strip_describe_prefix(test_name, describe_name) do
    String.replace_prefix(test_name, describe_name <> " ", "")
  end
end