Class: Ace::Support::Config::Organisms::ConfigResolver

Inherits:
Object
  • Object
show all
Defined in:
lib/ace/support/config/organisms/config_resolver.rb

Overview

Complete configuration cascade resolution

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(config_dir: ".ace", defaults_dir: ".ace-defaults", gem_path: nil, file_patterns: nil, merge_strategy: :replace, cache_namespaces: false, test_mode: false, mock_config: nil) ⇒ ConfigResolver

Initialize config resolver with configurable options

Parameters:

  • config_dir (String) (defaults to: ".ace")

    User config folder name (default: “.ace”)

  • defaults_dir (String) (defaults to: ".ace-defaults")

    Gem defaults folder name (default: “.ace-defaults”)

  • gem_path (String, nil) (defaults to: nil)

    Gem root path for defaults

  • file_patterns (Array<String>, nil) (defaults to: nil)

    Patterns for config files

  • merge_strategy (Symbol) (defaults to: :replace)

    How to merge arrays (:replace, :concat, :union)

  • cache_namespaces (Boolean) (defaults to: false)

    Whether to cache resolve_namespace results (default: false)

  • test_mode (Boolean) (defaults to: false)

    Skip filesystem searches and return empty/mock config (default: false)

  • mock_config (Hash, nil) (defaults to: nil)

    Mock config data to return in test mode



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/ace/support/config/organisms/config_resolver.rb', line 23

def initialize(
  config_dir: ".ace",
  defaults_dir: ".ace-defaults",
  gem_path: nil,
  file_patterns: nil,
  merge_strategy: :replace,
  cache_namespaces: false,
  test_mode: false,
  mock_config: nil
)
  @config_dir = config_dir
  @defaults_dir = defaults_dir
  @gem_path = gem_path
  @file_patterns = file_patterns || Molecules::ConfigFinder::DEFAULT_FILE_PATTERNS
  @merge_strategy = merge_strategy
  @cache_namespaces = cache_namespaces
  @namespace_cache = {} if cache_namespaces
  @test_mode = test_mode
  @mock_config = mock_config || {}
end

Instance Attribute Details

#config_dirObject (readonly)

Returns the value of attribute config_dir.



11
12
13
# File 'lib/ace/support/config/organisms/config_resolver.rb', line 11

def config_dir
  @config_dir
end

#defaults_dirObject (readonly)

Returns the value of attribute defaults_dir.



11
12
13
# File 'lib/ace/support/config/organisms/config_resolver.rb', line 11

def defaults_dir
  @defaults_dir
end

#file_patternsObject (readonly)

Returns the value of attribute file_patterns.



12
13
14
# File 'lib/ace/support/config/organisms/config_resolver.rb', line 12

def file_patterns
  @file_patterns
end

#gem_pathObject (readonly)

Returns the value of attribute gem_path.



11
12
13
# File 'lib/ace/support/config/organisms/config_resolver.rb', line 11

def gem_path
  @gem_path
end

#merge_strategyObject (readonly)

Returns the value of attribute merge_strategy.



12
13
14
# File 'lib/ace/support/config/organisms/config_resolver.rb', line 12

def merge_strategy
  @merge_strategy
end

#test_modeObject (readonly)

Returns the value of attribute test_mode.



12
13
14
# File 'lib/ace/support/config/organisms/config_resolver.rb', line 12

def test_mode
  @test_mode
end

Class Method Details

.create_default(path = "./.ace/settings.yml") ⇒ Models::Config

Create default config structure

Parameters:

  • path (String) (defaults to: "./.ace/settings.yml")

    Where to create config

Returns:



324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
# File 'lib/ace/support/config/organisms/config_resolver.rb', line 324

def self.create_default(path = "./.ace/settings.yml")
  default_config = {
    "config" => {
      "version" => Ace::Support::Config::VERSION,
      "cascade" => {
        "enabled" => true,
        "merge_strategy" => :replace
      }
    }
  }

  # Ensure directory exists
  dir = File.dirname(path)
  FileUtils.mkdir_p(dir)

  # Save config
  config = Models::Config.new(default_config, source: path)
  Molecules::YamlLoader.save_file(config, path)

  config
end

Instance Method Details

#find_configsArray<Models::CascadePath>

Find config files

In test mode, returns an empty array without filesystem access.

Returns:



251
252
253
254
255
256
# File 'lib/ace/support/config/organisms/config_resolver.rb', line 251

def find_configs
  # Short-circuit in test mode
  return [] if test_mode?

  build_finder.find_all
end

#get(*keys) ⇒ Object

Resolve and get value by key path (uses memoized resolve)

Parameters:

  • keys (Array<String,Symbol>)

    Key path

Returns:

  • (Object)

    Value at key path



67
68
69
# File 'lib/ace/support/config/organisms/config_resolver.rb', line 67

def get(*keys)
  resolve.get(*keys)
end

#reset!Object

Reset memoized configuration (useful for tests or dynamic reloading)



59
60
61
62
# File 'lib/ace/support/config/organisms/config_resolver.rb', line 59

def reset!
  @resolved_config = nil
  @namespace_cache&.clear
end

#resolveModels::Config

Resolve configuration cascade (memoized)

In test mode, returns mock config immediately without filesystem access.

Returns:



54
55
56
# File 'lib/ace/support/config/organisms/config_resolver.rb', line 54

def resolve
  @resolved_config ||= test_mode? ? resolve_test_mode : resolve_without_cache
end

#resolve_file(patterns) ⇒ Models::Config

Resolve configuration for specific file patterns (not memoized)

Unlike resolve, this method always re-reads files to support different pattern sets. Use resolve for repeated access to the same configuration.

In test mode, returns mock config immediately without filesystem access.

Parameters:

  • patterns (Array<String>, String)

    File patterns to search for

Returns:



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/ace/support/config/organisms/config_resolver.rb', line 164

def resolve_file(patterns)
  # Short-circuit in test mode
  return resolve_test_mode if test_mode?

  # Create finder with specified patterns
  finder = Molecules::ConfigFinder.new(
    config_dir: config_dir,
    defaults_dir: defaults_dir,
    gem_path: gem_path,
    file_patterns: Array(patterns)
  )

  cascade_paths = finder.find_all.select(&:exists)

  if cascade_paths.empty?
    return Models::Config.new({}, source: "no_config_found", merge_strategy: merge_strategy)
  end

  # Load and merge configs
  configs = cascade_paths.map do |cascade_path|
    Molecules::YamlLoader.load_file(cascade_path.path)
  end

  merged_data = configs.reverse.reduce({}) do |result, config|
    Atoms::DeepMerger.merge(
      result,
      config.data,
      array_strategy: merge_strategy
    )
  end

  sources = cascade_paths.map(&:path).join(" -> ")
  Models::Config.new(
    merged_data,
    source: sources,
    merge_strategy: merge_strategy
  )
end

#resolve_for(patterns) ⇒ Models::Config

Deprecated.

Use #resolve_file instead

Returns Resolved configuration.

Parameters:

  • patterns (Array<String>, String)

    File patterns to search for

Returns:



206
207
208
209
# File 'lib/ace/support/config/organisms/config_resolver.rb', line 206

def resolve_for(patterns)
  warn "[DEPRECATED] resolve_for() is deprecated. Use resolve_file() instead.", uplevel: 1
  resolve_file(patterns)
end

#resolve_namespace(*segments, filename: "config") ⇒ Models::Config

Resolve configuration for a namespace path (optionally memoized)

Builds file patterns from path segments and automatically appends .yml/.yaml extensions. This is a convenience wrapper around resolve_for.

By default, resolve_namespace is NOT memoized to ensure fresh config reads. To enable caching for performance in tight loops, initialize the resolver with ‘cache_namespaces: true`.

Examples:

Single segment with default filename

resolve_namespace("docs")
# Resolves: ["docs/config.yml", "docs/config.yaml"]

Multiple segments

resolve_namespace("git", "worktree")
# Resolves: ["git/worktree/config.yml", "git/worktree/config.yaml"]

Custom filename

resolve_namespace("lint", filename: "kramdown")
# Resolves: ["lint/kramdown.yml", "lint/kramdown.yaml"]

Root config with custom filename

resolve_namespace(filename: "settings")
# Resolves: ["settings.yml", "settings.yaml"]

With caching enabled

resolver = Ace::Support::Config.create(cache_namespaces: true)
resolver.resolve_namespace("docs")  # reads from disk
resolver.resolve_namespace("docs")  # returns cached result

Parameters:

  • segments (Array<String>)

    Path segments (e.g., “docs”, “config”)

  • filename (String) (defaults to: "config")

    Filename without extension (default: “config”)

Returns:

See Also:



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/ace/support/config/organisms/config_resolver.rb', line 107

def resolve_namespace(*segments, filename: "config")
  # Sanitize segments:
  # - flatten: handle nested arrays like resolve_namespace(["git", "worktree"])
  # - compact: remove nil values
  # - stringify + strip: handle symbols and whitespace
  # - reject empty: filter out empty strings after stripping
  clean_segments = segments.flatten.compact.map(&:to_s).map(&:strip).reject(&:empty?)

  # Security: Validate segments don't contain path traversal or absolute paths
  validate_namespace_segments!(clean_segments)

  # Strip .yml/.yaml extension if user accidentally included it
  clean_filename = filename.to_s.sub(/\.ya?ml\z/i, "")

  # Security: Reject empty filenames (e.g., filename: ".yml" becomes empty after stripping)
  if clean_filename.empty?
    raise ArgumentError, "Invalid filename: #{filename.inspect} (filename cannot be empty)"
  end

  # Security: Validate filename doesn't contain path traversal
  validate_namespace_segments!([clean_filename])

  # Check cache if enabled
  if @cache_namespaces
    cache_key = [clean_segments, clean_filename].hash
    return @namespace_cache[cache_key] if @namespace_cache.key?(cache_key)
  end

  # Generate both .yml and .yaml patterns using File.join for cross-platform compatibility
  patterns = if clean_segments.empty?
    ["#{clean_filename}.yml", "#{clean_filename}.yaml"]
  else
    base_path = File.join(*clean_segments)
    [File.join(base_path, "#{clean_filename}.yml"), File.join(base_path, "#{clean_filename}.yaml")]
  end

  result = resolve_file(patterns)

  # Store in cache if enabled
  if @cache_namespaces
    cache_key = [clean_segments, clean_filename].hash
    @namespace_cache[cache_key] = result
  end

  result
end

#resolve_type(type) ⇒ Models::Config?

Get config from specific type

In test mode, returns mock config immediately without filesystem access.

Parameters:

  • type (Symbol)

    Config type (:local, :home, :gem)

Returns:



217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
# File 'lib/ace/support/config/organisms/config_resolver.rb', line 217

def resolve_type(type)
  # Short-circuit in test mode
  return resolve_test_mode if test_mode?

  finder = build_finder

  paths = finder.find_by_type(type).select(&:exists)
  return nil if paths.empty?

  # Merge configs of same type
  configs = paths.map do |path|
    Molecules::YamlLoader.load_file(path.path)
  end

  merged_data = configs.reduce({}) do |result, config|
    Atoms::DeepMerger.merge(
      result,
      config.data,
      array_strategy: merge_strategy
    )
  end

  Models::Config.new(
    merged_data,
    source: "#{type}_configs",
    merge_strategy: merge_strategy
  )
end

#test_mode?Boolean

Check if test mode is active

Returns:

  • (Boolean)

    True if test mode is active



46
47
48
# File 'lib/ace/support/config/organisms/config_resolver.rb', line 46

def test_mode?
  @test_mode == true
end