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.
- checksums.yaml +7 -0
- data/.ace-defaults/config/config.yml +23 -0
- data/CHANGELOG.md +224 -0
- data/LICENSE +21 -0
- data/README.md +35 -0
- data/Rakefile +15 -0
- data/lib/ace/support/config/atoms/deep_merger.rb +126 -0
- data/lib/ace/support/config/atoms/path_rule_matcher.rb +118 -0
- data/lib/ace/support/config/atoms/path_validator.rb +76 -0
- data/lib/ace/support/config/atoms/yaml_parser.rb +50 -0
- data/lib/ace/support/config/errors.rb +24 -0
- data/lib/ace/support/config/models/cascade_path.rb +94 -0
- data/lib/ace/support/config/models/config.rb +134 -0
- data/lib/ace/support/config/models/config_group.rb +57 -0
- data/lib/ace/support/config/molecules/config_finder.rb +230 -0
- data/lib/ace/support/config/molecules/file_config_resolver.rb +419 -0
- data/lib/ace/support/config/molecules/project_config_scanner.rb +164 -0
- data/lib/ace/support/config/molecules/yaml_loader.rb +81 -0
- data/lib/ace/support/config/organisms/config_resolver.rb +349 -0
- data/lib/ace/support/config/organisms/virtual_config_resolver.rb +141 -0
- data/lib/ace/support/config/version.rb +9 -0
- data/lib/ace/support/config.rb +277 -0
- data/lib/ace/support.rb +4 -0
- metadata +109 -0
|
@@ -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
|