ace-support-config 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ace
6
+ module Support
7
+ module Config
8
+ module Organisms
9
+ # Complete configuration cascade resolution
10
+ class ConfigResolver
11
+ attr_reader :config_dir, :defaults_dir, :gem_path
12
+ attr_reader :file_patterns, :merge_strategy, :test_mode
13
+
14
+ # Initialize config resolver with configurable options
15
+ # @param config_dir [String] User config folder name (default: ".ace")
16
+ # @param defaults_dir [String] Gem defaults folder name (default: ".ace-defaults")
17
+ # @param gem_path [String, nil] Gem root path for defaults
18
+ # @param file_patterns [Array<String>, nil] Patterns for config files
19
+ # @param merge_strategy [Symbol] How to merge arrays (:replace, :concat, :union)
20
+ # @param cache_namespaces [Boolean] Whether to cache resolve_namespace results (default: false)
21
+ # @param test_mode [Boolean] Skip filesystem searches and return empty/mock config (default: false)
22
+ # @param mock_config [Hash, nil] Mock config data to return in test mode
23
+ def initialize(
24
+ config_dir: ".ace",
25
+ defaults_dir: ".ace-defaults",
26
+ gem_path: nil,
27
+ file_patterns: nil,
28
+ merge_strategy: :replace,
29
+ cache_namespaces: false,
30
+ test_mode: false,
31
+ mock_config: nil
32
+ )
33
+ @config_dir = config_dir
34
+ @defaults_dir = defaults_dir
35
+ @gem_path = gem_path
36
+ @file_patterns = file_patterns || Molecules::ConfigFinder::DEFAULT_FILE_PATTERNS
37
+ @merge_strategy = merge_strategy
38
+ @cache_namespaces = cache_namespaces
39
+ @namespace_cache = {} if cache_namespaces
40
+ @test_mode = test_mode
41
+ @mock_config = mock_config || {}
42
+ end
43
+
44
+ # Check if test mode is active
45
+ # @return [Boolean] True if test mode is active
46
+ def test_mode?
47
+ @test_mode == true
48
+ end
49
+
50
+ # Resolve configuration cascade (memoized)
51
+ #
52
+ # In test mode, returns mock config immediately without filesystem access.
53
+ # @return [Models::Config] Merged configuration
54
+ def resolve
55
+ @resolved_config ||= test_mode? ? resolve_test_mode : resolve_without_cache
56
+ end
57
+
58
+ # Reset memoized configuration (useful for tests or dynamic reloading)
59
+ def reset!
60
+ @resolved_config = nil
61
+ @namespace_cache&.clear
62
+ end
63
+
64
+ # Resolve and get value by key path (uses memoized resolve)
65
+ # @param keys [Array<String,Symbol>] Key path
66
+ # @return [Object] Value at key path
67
+ def get(*keys)
68
+ resolve.get(*keys)
69
+ end
70
+
71
+ # Resolve configuration for a namespace path (optionally memoized)
72
+ #
73
+ # Builds file patterns from path segments and automatically appends
74
+ # .yml/.yaml extensions. This is a convenience wrapper around resolve_for.
75
+ #
76
+ # By default, resolve_namespace is NOT memoized to ensure fresh config reads.
77
+ # To enable caching for performance in tight loops, initialize the resolver
78
+ # with `cache_namespaces: true`.
79
+ #
80
+ # @param segments [Array<String>] Path segments (e.g., "docs", "config")
81
+ # @param filename [String] Filename without extension (default: "config")
82
+ # @return [Models::Config] Resolved configuration
83
+ #
84
+ # @example Single segment with default filename
85
+ # resolve_namespace("docs")
86
+ # # Resolves: ["docs/config.yml", "docs/config.yaml"]
87
+ #
88
+ # @example Multiple segments
89
+ # resolve_namespace("git", "worktree")
90
+ # # Resolves: ["git/worktree/config.yml", "git/worktree/config.yaml"]
91
+ #
92
+ # @example Custom filename
93
+ # resolve_namespace("lint", filename: "kramdown")
94
+ # # Resolves: ["lint/kramdown.yml", "lint/kramdown.yaml"]
95
+ #
96
+ # @example Root config with custom filename
97
+ # resolve_namespace(filename: "settings")
98
+ # # Resolves: ["settings.yml", "settings.yaml"]
99
+ #
100
+ # @example With caching enabled
101
+ # resolver = Ace::Support::Config.create(cache_namespaces: true)
102
+ # resolver.resolve_namespace("docs") # reads from disk
103
+ # resolver.resolve_namespace("docs") # returns cached result
104
+ #
105
+ # @see #resolve_file For pattern-based resolution
106
+ # @see #resolve For default configuration cascade
107
+ def resolve_namespace(*segments, filename: "config")
108
+ # Sanitize segments:
109
+ # - flatten: handle nested arrays like resolve_namespace(["git", "worktree"])
110
+ # - compact: remove nil values
111
+ # - stringify + strip: handle symbols and whitespace
112
+ # - reject empty: filter out empty strings after stripping
113
+ clean_segments = segments.flatten.compact.map(&:to_s).map(&:strip).reject(&:empty?)
114
+
115
+ # Security: Validate segments don't contain path traversal or absolute paths
116
+ validate_namespace_segments!(clean_segments)
117
+
118
+ # Strip .yml/.yaml extension if user accidentally included it
119
+ clean_filename = filename.to_s.sub(/\.ya?ml\z/i, "")
120
+
121
+ # Security: Reject empty filenames (e.g., filename: ".yml" becomes empty after stripping)
122
+ if clean_filename.empty?
123
+ raise ArgumentError, "Invalid filename: #{filename.inspect} (filename cannot be empty)"
124
+ end
125
+
126
+ # Security: Validate filename doesn't contain path traversal
127
+ validate_namespace_segments!([clean_filename])
128
+
129
+ # Check cache if enabled
130
+ if @cache_namespaces
131
+ cache_key = [clean_segments, clean_filename].hash
132
+ return @namespace_cache[cache_key] if @namespace_cache.key?(cache_key)
133
+ end
134
+
135
+ # Generate both .yml and .yaml patterns using File.join for cross-platform compatibility
136
+ patterns = if clean_segments.empty?
137
+ ["#{clean_filename}.yml", "#{clean_filename}.yaml"]
138
+ else
139
+ base_path = File.join(*clean_segments)
140
+ [File.join(base_path, "#{clean_filename}.yml"), File.join(base_path, "#{clean_filename}.yaml")]
141
+ end
142
+
143
+ result = resolve_file(patterns)
144
+
145
+ # Store in cache if enabled
146
+ if @cache_namespaces
147
+ cache_key = [clean_segments, clean_filename].hash
148
+ @namespace_cache[cache_key] = result
149
+ end
150
+
151
+ result
152
+ end
153
+
154
+ # Resolve configuration for specific file patterns (not memoized)
155
+ #
156
+ # Unlike `resolve`, this method always re-reads files to support
157
+ # different pattern sets. Use `resolve` for repeated access to
158
+ # the same configuration.
159
+ #
160
+ # In test mode, returns mock config immediately without filesystem access.
161
+ #
162
+ # @param patterns [Array<String>, String] File patterns to search for
163
+ # @return [Models::Config] Resolved configuration
164
+ def resolve_file(patterns)
165
+ # Short-circuit in test mode
166
+ return resolve_test_mode if test_mode?
167
+
168
+ # Create finder with specified patterns
169
+ finder = Molecules::ConfigFinder.new(
170
+ config_dir: config_dir,
171
+ defaults_dir: defaults_dir,
172
+ gem_path: gem_path,
173
+ file_patterns: Array(patterns)
174
+ )
175
+
176
+ cascade_paths = finder.find_all.select(&:exists)
177
+
178
+ if cascade_paths.empty?
179
+ return Models::Config.new({}, source: "no_config_found", merge_strategy: merge_strategy)
180
+ end
181
+
182
+ # Load and merge configs
183
+ configs = cascade_paths.map do |cascade_path|
184
+ Molecules::YamlLoader.load_file(cascade_path.path)
185
+ end
186
+
187
+ merged_data = configs.reverse.reduce({}) do |result, config|
188
+ Atoms::DeepMerger.merge(
189
+ result,
190
+ config.data,
191
+ array_strategy: merge_strategy
192
+ )
193
+ end
194
+
195
+ sources = cascade_paths.map(&:path).join(" -> ")
196
+ Models::Config.new(
197
+ merged_data,
198
+ source: sources,
199
+ merge_strategy: merge_strategy
200
+ )
201
+ end
202
+
203
+ # @deprecated Use {#resolve_file} instead
204
+ # @param patterns [Array<String>, String] File patterns to search for
205
+ # @return [Models::Config] Resolved configuration
206
+ def resolve_for(patterns)
207
+ warn "[DEPRECATED] resolve_for() is deprecated. Use resolve_file() instead.", uplevel: 1
208
+ resolve_file(patterns)
209
+ end
210
+
211
+ # Get config from specific type
212
+ #
213
+ # In test mode, returns mock config immediately without filesystem access.
214
+ #
215
+ # @param type [Symbol] Config type (:local, :home, :gem)
216
+ # @return [Models::Config, nil] Config from that type
217
+ def resolve_type(type)
218
+ # Short-circuit in test mode
219
+ return resolve_test_mode if test_mode?
220
+
221
+ finder = build_finder
222
+
223
+ paths = finder.find_by_type(type).select(&:exists)
224
+ return nil if paths.empty?
225
+
226
+ # Merge configs of same type
227
+ configs = paths.map do |path|
228
+ Molecules::YamlLoader.load_file(path.path)
229
+ end
230
+
231
+ merged_data = configs.reduce({}) do |result, config|
232
+ Atoms::DeepMerger.merge(
233
+ result,
234
+ config.data,
235
+ array_strategy: merge_strategy
236
+ )
237
+ end
238
+
239
+ Models::Config.new(
240
+ merged_data,
241
+ source: "#{type}_configs",
242
+ merge_strategy: merge_strategy
243
+ )
244
+ end
245
+
246
+ # Find config files
247
+ #
248
+ # In test mode, returns an empty array without filesystem access.
249
+ #
250
+ # @return [Array<Models::CascadePath>] All potential config paths
251
+ def find_configs
252
+ # Short-circuit in test mode
253
+ return [] if test_mode?
254
+
255
+ build_finder.find_all
256
+ end
257
+
258
+ private
259
+
260
+ # Internal: resolve in test mode (no filesystem access)
261
+ # @return [Models::Config] Mock configuration
262
+ def resolve_test_mode
263
+ Models::Config.new(
264
+ @mock_config,
265
+ source: "test_mode",
266
+ merge_strategy: merge_strategy
267
+ )
268
+ end
269
+
270
+ # Internal: resolve configuration cascade without caching
271
+ # @return [Models::Config] Merged configuration
272
+ def resolve_without_cache
273
+ finder = build_finder
274
+
275
+ cascade_paths = finder.find_all.select(&:exists)
276
+
277
+ if cascade_paths.empty?
278
+ return Models::Config.new({}, source: "defaults", merge_strategy: merge_strategy)
279
+ end
280
+
281
+ # Load all configs in cascade order
282
+ configs = cascade_paths.map do |cascade_path|
283
+ Molecules::YamlLoader.load_file(cascade_path.path)
284
+ end
285
+
286
+ # Merge in reverse order (lowest priority first)
287
+ merged_data = configs.reverse.reduce({}) do |result, config|
288
+ Atoms::DeepMerger.merge(
289
+ result,
290
+ config.data,
291
+ array_strategy: merge_strategy
292
+ )
293
+ end
294
+
295
+ sources = cascade_paths.map(&:path).join(" -> ")
296
+ Models::Config.new(
297
+ merged_data,
298
+ source: sources,
299
+ merge_strategy: merge_strategy
300
+ )
301
+ end
302
+
303
+ # Build a ConfigFinder with current settings
304
+ def build_finder
305
+ Molecules::ConfigFinder.new(
306
+ config_dir: config_dir,
307
+ defaults_dir: defaults_dir,
308
+ gem_path: gem_path,
309
+ file_patterns: file_patterns
310
+ )
311
+ end
312
+
313
+ # Validate namespace segments for security
314
+ # Delegates to Atoms::PathValidator for the actual validation
315
+ # @param segments [Array<String>] Segments to validate
316
+ # @raise [ArgumentError] If any segment contains invalid characters
317
+ def validate_namespace_segments!(segments)
318
+ Atoms::PathValidator.validate_segments!(segments)
319
+ end
320
+
321
+ # Create default config structure
322
+ # @param path [String] Where to create config
323
+ # @return [Models::Config] Created configuration
324
+ def self.create_default(path = "./.ace/settings.yml")
325
+ default_config = {
326
+ "config" => {
327
+ "version" => Ace::Support::Config::VERSION,
328
+ "cascade" => {
329
+ "enabled" => true,
330
+ "merge_strategy" => :replace
331
+ }
332
+ }
333
+ }
334
+
335
+ # Ensure directory exists
336
+ dir = File.dirname(path)
337
+ FileUtils.mkdir_p(dir)
338
+
339
+ # Save config
340
+ config = Models::Config.new(default_config, source: path)
341
+ Molecules::YamlLoader.save_file(config, path)
342
+
343
+ config
344
+ end
345
+ end
346
+ end
347
+ end
348
+ end
349
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Config
6
+ module Organisms
7
+ # Resolves configuration files using a cascade system
8
+ # Provides a virtual filesystem view where nearest config wins
9
+ class VirtualConfigResolver
10
+ attr_reader :start_path, :virtual_map, :config_dir, :defaults_dir, :gem_path
11
+
12
+ # Initialize with configurable folder names
13
+ # @param config_dir [String] Config folder name (default: ".ace")
14
+ # @param defaults_dir [String] Defaults folder name (default: ".ace-defaults")
15
+ # @param start_path [String, nil] Starting path for traversal
16
+ # @param gem_path [String, nil] Gem root path for defaults (lowest priority)
17
+ def initialize(config_dir: ".ace", defaults_dir: ".ace-defaults", start_path: nil, gem_path: nil)
18
+ @config_dir = config_dir
19
+ @defaults_dir = defaults_dir
20
+ @start_path = start_path || Dir.pwd
21
+ @gem_path = gem_path
22
+ @virtual_map = build_virtual_map
23
+ end
24
+
25
+ # Get absolute path for a relative config path
26
+ # @param relative_path [String] Path relative to config directory
27
+ # @return [String, nil] Absolute path to the file, or nil if not found
28
+ def resolve_path(relative_path)
29
+ normalized = normalize_path(relative_path)
30
+ @virtual_map[normalized]
31
+ end
32
+
33
+ # Get all files matching a pattern
34
+ # @param pattern [String] Glob pattern relative to config dir
35
+ # @return [Hash<String, String>] Map of relative paths to absolute paths
36
+ def glob(pattern)
37
+ results = {}
38
+
39
+ @virtual_map.each do |relative_path, absolute_path|
40
+ # FNM_PATHNAME ensures * doesn't match /
41
+ # FNM_DOTMATCH ensures hidden files are matched if pattern starts with .
42
+ if File.fnmatch?(pattern, relative_path, File::FNM_PATHNAME | File::FNM_DOTMATCH)
43
+ results[relative_path] = absolute_path
44
+ end
45
+ end
46
+
47
+ results
48
+ end
49
+
50
+ # Check if a relative path exists in the virtual map
51
+ # @param relative_path [String] Path relative to config directory
52
+ # @return [Boolean]
53
+ def exists?(relative_path)
54
+ @virtual_map.key?(normalize_path(relative_path))
55
+ end
56
+
57
+ # Get all discovered config directories in priority order
58
+ # @return [Array<String>] Paths to config directories (nearest first)
59
+ def config_directories
60
+ @config_directories ||= discover_config_directories
61
+ end
62
+
63
+ # Reload the virtual map (useful if config files change)
64
+ def reload!
65
+ @virtual_map = build_virtual_map
66
+ @config_directories = nil
67
+ end
68
+
69
+ private
70
+
71
+ def build_virtual_map
72
+ map = {}
73
+
74
+ # Get all config directories in reverse order (farthest first)
75
+ # so that nearer configs override farther ones
76
+ dirs = discover_config_directories.reverse
77
+
78
+ dirs.each do |config_directory|
79
+ next unless Dir.exist?(config_directory)
80
+
81
+ # Get absolute path of config directory for validation
82
+ config_dir_abs = File.expand_path(config_directory)
83
+
84
+ # Find all files under this config directory
85
+ Dir.glob(File.join(config_directory, "**", "*")).each do |file_path|
86
+ next unless File.file?(file_path)
87
+
88
+ # Validate path stays within config directory (prevent traversal)
89
+ file_abs = File.expand_path(file_path)
90
+ next unless file_abs.start_with?(config_dir_abs + File::SEPARATOR) ||
91
+ file_abs == config_dir_abs
92
+
93
+ # Get path relative to config directory
94
+ relative_path = file_abs.sub("#{config_dir_abs}/", "")
95
+
96
+ # Store in map (later entries override earlier ones)
97
+ map[relative_path] = file_path
98
+ end
99
+ end
100
+
101
+ map
102
+ end
103
+
104
+ def discover_config_directories
105
+ # Use DirectoryTraverser to find all config directories
106
+ traverser = Ace::Support::Fs::Molecules::DirectoryTraverser.new(
107
+ config_dir: @config_dir,
108
+ start_path: @start_path
109
+ )
110
+
111
+ # Get config directories from current to project root
112
+ dirs = traverser.find_config_directories
113
+
114
+ # Add user home config if it exists and not already included
115
+ home_config = File.expand_path("~/#{@config_dir}")
116
+ if Dir.exist?(home_config) && !dirs.include?(home_config)
117
+ dirs << home_config
118
+ end
119
+
120
+ # Add gem defaults if gem_path is provided (lowest priority)
121
+ if @gem_path
122
+ gem_defaults = File.join(@gem_path, @defaults_dir)
123
+ if Dir.exist?(gem_defaults) && !dirs.include?(gem_defaults)
124
+ dirs << gem_defaults
125
+ end
126
+ end
127
+
128
+ dirs
129
+ end
130
+
131
+ def normalize_path(path)
132
+ # Remove leading ./ or config_dir/ if present
133
+ path = path.to_s
134
+ path = path.sub(/^\.\//, "")
135
+ path.sub(/^#{Regexp.escape(@config_dir)}\//, "")
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Config
6
+ VERSION = "0.9.0"
7
+ end
8
+ end
9
+ end