# frozen_string_literal: true require 'yaml' module Opendax # Loads config/app.yml and deep-merges an environment overlay when present. # # Overlay resolution (first match wins): # 1. OPENDAX_ENV=local|staging|production → config/app.{env}.yml # 2. If OPENDAX_ENV unset and config/app.local.yml exists → use local overlay # # Never commit real secrets to config/app.yml — use app.local.yml or app.production.yml. module ConfigLoader BASE_PATH = 'config/app.yml' module_function def load base = load_yaml_hash!(BASE_PATH, 'base config') overlay_path = resolve_overlay_path return base unless overlay_path && File.exist?(overlay_path) overlay = load_yaml_hash!(overlay_path, 'overlay config') deep_merge(base, overlay) end def load_yaml_hash!(path, label) data = YAML.load_file(path) return data if data.is_a?(Hash) raise TypeError, "#{label} #{path} must be a YAML mapping (Hash), got #{data.class}. " \ 'Check syntax — quote values that contain colons (e.g. database passwords).' end def resolve_overlay_path env = ENV['OPENDAX_ENV'].to_s.strip if env.empty? return 'config/app.local.yml' if File.exist?('config/app.local.yml') return nil end "config/app.#{env}.yml" end def active_overlay path = resolve_overlay_path path if path && File.exist?(path) end def deep_merge(base, overlay) unless base.is_a?(Hash) && overlay.is_a?(Hash) raise TypeError, "deep_merge expects Hash + Hash, got #{base.class} + #{overlay.class}" end base.merge(overlay) do |key, old_val, new_val| if old_val.is_a?(Hash) && new_val.is_a?(Hash) deep_merge(old_val, new_val) elsif old_val.is_a?(Hash) && !new_val.is_a?(Hash) raise TypeError, "Config type mismatch at #{key}: base has Hash but overlay has " \ "#{new_val.class}. Quote scalar values in app.staging.yml (passwords with ':' need quotes)." else new_val end end end end end