defmodule CCXT.Spec.Merger do @moduledoc """ Deep-merge logic for curated spec overlays. When a curated spec contains `_curated_overrides: true`, the extracted spec loads as a base and curated fields are deep-merged on top. This avoids duplicating the entire extracted spec when only a few fields need overriding. ## Merge Rules - **Scalars**: curated replaces extracted - **Maps**: deep-merge (curated keys override, extracted keys preserved) - **`endpoints` list**: merge by `:name` (matching endpoint replaced wholesale, new endpoints appended) - **Other lists**: curated replaces entirely - **Without `_curated_overrides`**: existing winner-take-all behavior preserved """ @sentinel_key :_curated_overrides @doc """ Returns true if the raw spec map contains the `_curated_overrides: true` sentinel. """ @spec overlay?(map()) :: boolean() def overlay?(map) when is_map(map) do Map.get(map, @sentinel_key) == true end @doc """ Deep-merges curated overrides on top of extracted base spec. Strips the `_curated_overrides` sentinel before merging. """ @spec merge(map(), map()) :: map() def merge(base, overrides) when is_map(base) and is_map(overrides) do overrides = Map.delete(overrides, @sentinel_key) deep_merge(base, overrides) end @doc false # Deep-merge two maps, with special handling for :endpoints lists. @spec deep_merge(map(), map()) :: map() defp deep_merge(base, overrides) do Map.merge(base, overrides, fn key, base_val, override_val -> resolve_conflict(key, base_val, override_val) end) end @doc false defp resolve_conflict(:endpoints, base_list, override_list) when is_list(base_list) and is_list(override_list) do merge_endpoints(base_list, override_list) end defp resolve_conflict(_key, base_val, override_val) when is_map(base_val) and is_map(override_val) do deep_merge(base_val, override_val) end defp resolve_conflict(_key, _base_val, override_val) do override_val end @doc false # Merges endpoint lists by :name — matching endpoints are replaced wholesale, # new endpoints are appended. @spec merge_endpoints(list(), list()) :: list() defp merge_endpoints(base_endpoints, override_endpoints) do override_names = MapSet.new(override_endpoints, &Map.get(&1, :name)) # Keep base endpoints that aren't overridden, then append all overrides kept = Enum.reject(base_endpoints, &(Map.get(&1, :name) in override_names)) kept ++ override_endpoints end end