lib/growth_book/parent_condition.ex

defmodule GrowthBook.ParentCondition do
  @moduledoc """
  A ParentCondition defines a prerequisite.

  Instead of evaluating against attributes, the condition evaluates against the returned value of the parent feature. The condition will always reference a "value" property. Here is an example of a gating prerequisite where the parent feature must be toggled on:

  ```
  %ParentCondition{
    id: "parent-feature",
    condition: %{
      "value": {
        "$exists": true
      }
    },
    gate: true
  }
  ```
  """

  alias GrowthBook.{
    Context,
    Condition,
    FeatureResult,
    ParentCondition
  }

  alias ParentCondition.{
    CyclingError,
    PrerequisiteError
  }

  require Logger

  defmodule CyclingError do
    @type t() :: %__MODULE__{message: String.t()}
    defexception [:message]
  end

  defmodule PrerequisiteError do
    @type t() :: %__MODULE__{message: String.t()}
    defexception [:message]
  end

  @typedoc """
  ParentCondition

  A **ParentCondition** consists of a parent feature's id (string), a condition (Condition), and an optional gate (boolean) flag.

  - **`id`** (`t:String.t/0`) - parent feature's id
  - **`condition`** (`t:GrowthBook.Condition.t/0`) - condition
  - **`gate`** (`t:boolean/0`) -  If gate is true, then this is a blocking feature-level prerequisite; otherwise it applies to the current rule only.
  """
  @type t() :: %__MODULE__{
          id: String.t(),
          condition: Condition.t(),
          gate: boolean()
        }

  @type error() :: CyclingError.t() | PrerequisiteError.t()

  defstruct [
    :id,
    :condition,
    gate: false
  ]

  @spec from_json(map()) :: ParentCondition.t()
  def from_json(map) when is_map(map) do
    %ParentCondition{
      id: map["id"],
      condition: map["condition"],
      gate: map["gate"] || false
    }
  end

  @spec eval(Context.t(), [ParentCondition.t()] | nil, [String.t()]) ::
          true | false | {:error, ParentCondition.error()}
  def eval(_, [], _), do: true
  def eval(_, nil, _), do: true

  def eval(%Context{} = context, [parent_condition | rest], path) do
    %ParentCondition{
      id: parent_feature_id,
      gate: gate,
      condition: condition
    } = parent_condition

    if parent_feature_id in path do
      error = "Cycling feature prerequisite: #{parent_feature_id}, path: #{inspect(path)}"
      Logger.debug(error)
      {:error, %CyclingError{message: error}}
    else
      case GrowthBook.feature(context, parent_feature_id, path) do
        %FeatureResult{source: :cyclic_prerequisite} ->
          error = "Cycling feature prerequisite: #{parent_feature_id}, path: #{inspect(path)}"
          {:error, %CyclingError{message: error}}

        %FeatureResult{value: value} ->
          case Condition.eval_condition(%{"value" => value}, condition) do
            true ->
              eval(context, rest, path)

            false when gate == true ->
              error = "Feature prerequisite missing: #{parent_feature_id}, path: #{inspect(path)}"
              Logger.debug(error)
              {:error, %PrerequisiteError{message: error}}

            false ->
              false
          end
      end
    end
  end
end