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,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "date"
5
+
6
+ module Ace
7
+ module Support
8
+ module Config
9
+ module Atoms
10
+ # Pure YAML parsing functions
11
+ module YamlParser
12
+ module_function
13
+
14
+ # Parse YAML string into Ruby hash
15
+ # @param yaml_string [String] YAML content to parse
16
+ # @return [Hash] Parsed YAML content
17
+ # @raise [YamlParseError] if parsing fails
18
+ def parse(yaml_string)
19
+ return {} if yaml_string.nil? || yaml_string.strip.empty?
20
+
21
+ YAML.safe_load(yaml_string, permitted_classes: [Symbol, Date], aliases: true)
22
+ rescue Psych::SyntaxError => e
23
+ raise YamlParseError, "Failed to parse YAML: #{e.message}"
24
+ rescue Encoding::CompatibilityError, Encoding::InvalidByteSequenceError => e
25
+ raise YamlParseError, "Failed to parse YAML (encoding error): #{e.message}"
26
+ end
27
+
28
+ # Convert Ruby hash to YAML string
29
+ # @param data [Hash] Data to convert
30
+ # @return [String] YAML representation
31
+ def dump(data)
32
+ return "" if data.nil? || data.empty?
33
+
34
+ YAML.dump(data)
35
+ end
36
+
37
+ # Check if string is valid YAML
38
+ # @param yaml_string [String] YAML content to validate
39
+ # @return [Boolean] true if valid YAML
40
+ def valid?(yaml_string)
41
+ parse(yaml_string)
42
+ true
43
+ rescue YamlParseError
44
+ false
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/fs"
4
+
5
+ module Ace
6
+ module Support
7
+ module Config
8
+ # Base error class for all ace-support-config errors
9
+ class Error < StandardError; end
10
+
11
+ # Raised when configuration file is not found
12
+ class ConfigNotFoundError < Error; end
13
+
14
+ # Raised when YAML parsing fails
15
+ class YamlParseError < Error; end
16
+
17
+ # Raised when a path cannot be resolved
18
+ class PathError < Error; end
19
+
20
+ # Raised when merge strategy is invalid
21
+ class MergeStrategyError < Error; end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Config
6
+ module Models
7
+ # Represents a configuration cascade path
8
+ class CascadePath
9
+ attr_reader :path, :priority, :exists, :type
10
+
11
+ # Initialize cascade path
12
+ # @param path [String] File system path
13
+ # @param priority [Integer] Priority (lower = higher priority)
14
+ # @param exists [Boolean] Whether path exists
15
+ # @param type [Symbol] Type of path (:local, :home, :gem)
16
+ def initialize(path:, priority: 100, exists: false, type: :local)
17
+ @path = path.to_s.freeze
18
+ @priority = priority
19
+ @exists = exists
20
+ @type = type
21
+ freeze
22
+ end
23
+
24
+ # Compare paths by priority
25
+ # @param other [CascadePath] Other path to compare
26
+ # @return [Integer] Comparison result
27
+ def <=>(other)
28
+ return nil unless other.is_a?(CascadePath)
29
+
30
+ priority <=> other.priority
31
+ end
32
+
33
+ # Check if this path should override another
34
+ # @param other [CascadePath] Other path
35
+ # @return [Boolean] true if this path has higher priority
36
+ def overrides?(other)
37
+ return false unless other.is_a?(CascadePath)
38
+
39
+ priority < other.priority
40
+ end
41
+
42
+ # Convert to string
43
+ # @return [String] The file path
44
+ def to_s
45
+ path
46
+ end
47
+
48
+ # Get path as Pathname
49
+ # @return [Pathname] Path object
50
+ def pathname
51
+ require "pathname"
52
+ Pathname.new(path)
53
+ end
54
+
55
+ # Check if path is absolute
56
+ # @return [Boolean] true if absolute path
57
+ def absolute?
58
+ pathname.absolute?
59
+ end
60
+
61
+ # Check if path is relative
62
+ # @return [Boolean] true if relative path
63
+ def relative?
64
+ !absolute?
65
+ end
66
+
67
+ def ==(other)
68
+ other.is_a?(self.class) &&
69
+ other.path == path &&
70
+ other.priority == priority &&
71
+ other.type == type
72
+ end
73
+
74
+ def hash
75
+ [path, priority, type].hash
76
+ end
77
+
78
+ alias_method :eql?, :==
79
+
80
+ def inspect
81
+ attrs = [
82
+ "path=#{path.inspect}",
83
+ "type=#{type}",
84
+ "priority=#{priority}",
85
+ exists ? "exists" : "missing"
86
+ ].join(" ")
87
+
88
+ "#<#{self.class.name} #{attrs}>"
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Config
6
+ module Models
7
+ # Configuration data structure
8
+ class Config
9
+ attr_reader :data, :source, :merge_strategy
10
+
11
+ # Initialize configuration
12
+ # @param data [Hash] Configuration data
13
+ # @param source [String] Source file or identifier
14
+ # @param merge_strategy [Symbol] How to merge with other configs
15
+ def initialize(data = {}, source: nil, merge_strategy: :replace)
16
+ @data = data || {}
17
+ @source = source
18
+ @merge_strategy = merge_strategy
19
+ freeze
20
+ end
21
+
22
+ # Get configuration value by key path
23
+ # @param keys [Array<String,Symbol>] Path to value
24
+ # @return [Object] Value at path or nil
25
+ def get(*keys)
26
+ keys = keys.flatten.map(&:to_s)
27
+ keys.reduce(data) do |current, key|
28
+ return nil unless current.is_a?(Hash)
29
+
30
+ current[key]
31
+ end
32
+ end
33
+
34
+ # Check if configuration has key path
35
+ # @param keys [Array<String,Symbol>] Path to check
36
+ # @return [Boolean] true if path exists
37
+ def key?(*keys)
38
+ !get(*keys).nil?
39
+ end
40
+
41
+ # Convert to hash
42
+ # @return [Hash] Configuration data
43
+ def to_h
44
+ data.dup
45
+ end
46
+
47
+ # Get all keys at root level
48
+ # @return [Array<String>] Root level keys
49
+ def keys
50
+ data.keys
51
+ end
52
+
53
+ # Check if configuration is empty
54
+ # @return [Boolean] true if no configuration data
55
+ def empty?
56
+ data.empty?
57
+ end
58
+
59
+ # Iterate over root level key-value pairs
60
+ def each(&block)
61
+ data.each(&block)
62
+ end
63
+
64
+ # Create new config with additional data merged in
65
+ # @param other_data [Hash] Data to merge
66
+ # @return [Config] New configuration instance
67
+ def merge(other_data)
68
+ merged_data = Atoms::DeepMerger.merge(
69
+ data,
70
+ other_data,
71
+ array_strategy: merge_strategy
72
+ )
73
+
74
+ self.class.new(
75
+ merged_data,
76
+ source: "#{source}+merged",
77
+ merge_strategy: merge_strategy
78
+ )
79
+ end
80
+
81
+ # Alias for backward compatibility
82
+ alias_method :with, :merge
83
+
84
+ # Factory method to wrap a hash and merge additional data, returning a hash
85
+ # Provides a convenient one-liner for the common pattern:
86
+ # Config.new(base, source: "...").merge(overrides).to_h
87
+ #
88
+ # @param base [Hash, nil] Base configuration data (nil coerced to empty hash)
89
+ # @param overrides [Hash, nil] Data to merge on top of base (default: {}, nil coerced to empty hash)
90
+ # @param source [String] Source identifier for debugging (default: "wrap")
91
+ # @param merge_strategy [Symbol] How to merge arrays (default: :replace)
92
+ # @return [Hash] Merged configuration as a plain hash
93
+ #
94
+ # @example Single hash wrapping
95
+ # Config.wrap(defaults)
96
+ # # => { "key" => "default_value" }
97
+ #
98
+ # @example Merge two hashes
99
+ # Config.wrap(defaults, overrides)
100
+ # # => { "key" => "override_value", "other" => "default" }
101
+ #
102
+ # @example With options
103
+ # Config.wrap(defaults, overrides, source: "git_config", merge_strategy: :union)
104
+ #
105
+ # @example Handling nil inputs (type coercion)
106
+ # Config.wrap(nil) # => {}
107
+ # Config.wrap({}, nil) # => {}
108
+ # Config.wrap(nil, nil) # => {}
109
+ #
110
+ def self.wrap(base, overrides = {}, source: "wrap", merge_strategy: :replace)
111
+ # Type coercion: ensure base and overrides are hashes to prevent unexpected behavior
112
+ base_hash = base.is_a?(Hash) ? base : {}
113
+ overrides_hash = overrides.is_a?(Hash) ? overrides : {}
114
+
115
+ new(base_hash, source: source, merge_strategy: merge_strategy)
116
+ .merge(overrides_hash)
117
+ .to_h
118
+ end
119
+
120
+ def ==(other)
121
+ other.is_a?(self.class) &&
122
+ other.data == data &&
123
+ other.source == source &&
124
+ other.merge_strategy == merge_strategy
125
+ end
126
+
127
+ def inspect
128
+ "#<#{self.class.name} source=#{source.inspect} keys=#{keys.inspect}>"
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Config
6
+ module Models
7
+ # ConfigGroup represents a set of files sharing the same effective config
8
+ class ConfigGroup
9
+ # Default scope name when no path rule matches and no distributed config is found
10
+ DEFAULT_SCOPE_NAME = "project default"
11
+
12
+ # rule_config: Original path rule config (before cascade merge), used for grouping
13
+ # - When a path rule matches, this contains only the rule's own overrides
14
+ # - nil for distributed config matches or project default
15
+ # - Separates grouping (use rule_config) from message generation (use config)
16
+ attr_reader :name, :source, :config, :rule_config, :files
17
+
18
+ def initialize(name:, source:, config:, files: [], rule_config: nil)
19
+ @name = name
20
+ @source = source
21
+ @config = config || {}
22
+ @rule_config = rule_config
23
+ @files = Array(files)
24
+ freeze
25
+ end
26
+
27
+ def add_file(file)
28
+ self.class.new(
29
+ name: name,
30
+ source: source,
31
+ config: config,
32
+ rule_config: rule_config,
33
+ files: files + [file]
34
+ )
35
+ end
36
+
37
+ def file_count
38
+ files.length
39
+ end
40
+
41
+ def ==(other)
42
+ other.is_a?(self.class) &&
43
+ other.name == name &&
44
+ other.source == source &&
45
+ other.config == config &&
46
+ other.rule_config == rule_config &&
47
+ other.files == files
48
+ end
49
+
50
+ def inspect
51
+ "#<#{self.class.name} name=#{name.inspect} files=#{files.length}>"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Config
6
+ module Molecules
7
+ # Find configuration files in cascade paths
8
+ class ConfigFinder
9
+ # Common config file patterns
10
+ DEFAULT_FILE_PATTERNS = %w[
11
+ settings.yml
12
+ settings.yaml
13
+ config.yml
14
+ config.yaml
15
+ ].freeze
16
+
17
+ attr_reader :config_dir, :defaults_dir, :gem_path, :file_patterns, :start_path
18
+
19
+ # Initialize finder with configurable paths
20
+ # @param config_dir [String] User config folder name (default: ".ace")
21
+ # @param defaults_dir [String] Gem defaults folder name (default: ".ace-defaults")
22
+ # @param gem_path [String, nil] Gem root path for defaults
23
+ # @param file_patterns [Array<String>] File patterns to look for
24
+ # @param use_traversal [Boolean] Whether to use directory traversal (default: true)
25
+ # @param start_path [String, nil] Starting path for traversal (default: Dir.pwd)
26
+ def initialize(
27
+ config_dir: ".ace",
28
+ defaults_dir: ".ace-defaults",
29
+ gem_path: nil,
30
+ file_patterns: DEFAULT_FILE_PATTERNS,
31
+ use_traversal: true,
32
+ start_path: nil
33
+ )
34
+ @config_dir = config_dir
35
+ @defaults_dir = defaults_dir
36
+ @gem_path = gem_path
37
+ @file_patterns = file_patterns
38
+ @use_traversal = use_traversal
39
+ @start_path = start_path ? Ace::Support::Fs::Atoms::PathExpander.expand(start_path) : Dir.pwd
40
+ @search_paths = build_search_paths
41
+ end
42
+
43
+ # Find all config files in cascade order
44
+ # @return [Array<Models::CascadePath>] Found config paths
45
+ def find_all
46
+ paths = []
47
+
48
+ @search_paths.each_with_index do |base_path, index|
49
+ priority = index * 10 # Lower index = higher priority
50
+
51
+ @file_patterns.each do |pattern|
52
+ found = find_in_path(base_path, pattern, priority)
53
+ paths.concat(found)
54
+ end
55
+ end
56
+
57
+ # Add gem defaults with lowest priority
58
+ gem_config = find_gem_defaults
59
+ paths.concat(gem_config) if gem_config
60
+
61
+ paths.sort
62
+ end
63
+
64
+ # Find first existing config file
65
+ # @return [Models::CascadePath, nil] First found config path
66
+ def find_first
67
+ find_all.find(&:exists)
68
+ end
69
+
70
+ # Find configs by type
71
+ # @param type [Symbol] Type to filter (:local, :home, :gem)
72
+ # @return [Array<Models::CascadePath>] Configs of given type
73
+ def find_by_type(type)
74
+ find_all.select { |path| path.type == type }
75
+ end
76
+
77
+ # Find a specific config file using the cascade
78
+ # @param filename [String] Specific filename to find
79
+ # @return [String, nil] Path to the first found config file
80
+ def find_file(filename)
81
+ # Use pre-built search_paths (respects @use_traversal setting)
82
+ @search_paths.each do |dir|
83
+ file_path = File.join(dir, filename)
84
+ return file_path if File.exist?(file_path)
85
+ end
86
+
87
+ # Check gem defaults if available
88
+ if @gem_path
89
+ gem_default_path = File.join(@gem_path, @defaults_dir, filename)
90
+ return gem_default_path if File.exist?(gem_default_path)
91
+ end
92
+
93
+ nil
94
+ end
95
+
96
+ # Find all instances of a config file in the cascade
97
+ # @param filename [String] Specific filename to find
98
+ # @return [Array<String>] All found file paths in cascade order
99
+ def find_all_files(filename)
100
+ files = []
101
+
102
+ # Use pre-built search_paths (respects @use_traversal setting)
103
+ @search_paths.each do |dir|
104
+ file_path = File.join(dir, filename)
105
+ files << file_path if File.exist?(file_path)
106
+ end
107
+
108
+ # Check gem defaults if available
109
+ if @gem_path
110
+ gem_default_path = File.join(@gem_path, @defaults_dir, filename)
111
+ files << gem_default_path if File.exist?(gem_default_path)
112
+ end
113
+
114
+ files
115
+ end
116
+
117
+ # Get the search paths being used
118
+ # @return [Array<String>] Ordered list of search paths
119
+ attr_reader :search_paths
120
+
121
+ private
122
+
123
+ # Build search paths using directory traversal
124
+ # @return [Array<String>] Expanded search paths
125
+ def build_search_paths
126
+ if @use_traversal
127
+ traverser = Ace::Support::Fs::Molecules::DirectoryTraverser.new(
128
+ config_dir: @config_dir,
129
+ start_path: @start_path
130
+ )
131
+ paths = traverser.find_config_directories
132
+
133
+ # Add home directory if not already included
134
+ home_config = File.expand_path("~/#{@config_dir}")
135
+ paths << home_config unless paths.include?(home_config)
136
+
137
+ paths
138
+ else
139
+ # Simple default paths
140
+ [
141
+ File.join(@start_path, @config_dir),
142
+ File.expand_path("~/#{@config_dir}")
143
+ ]
144
+ end
145
+ end
146
+
147
+ # Find config files in a specific path with pattern
148
+ # @param base_path [String] Base path to search
149
+ # @param pattern [String] File pattern
150
+ # @param base_priority [Integer] Base priority for found files
151
+ # @return [Array<Models::CascadePath>] Found paths
152
+ def find_in_path(base_path, pattern, base_priority)
153
+ paths = []
154
+ full_pattern = File.join(base_path, pattern)
155
+
156
+ # Determine type based on path
157
+ type = determine_type(base_path)
158
+
159
+ Dir.glob(full_pattern).each_with_index do |file, index|
160
+ next unless File.file?(file)
161
+
162
+ paths << Models::CascadePath.new(
163
+ path: file,
164
+ priority: base_priority + index,
165
+ exists: true,
166
+ type: type
167
+ )
168
+ end
169
+
170
+ # If no files found but we're looking for a specific file, add as missing
171
+ if paths.empty? && !pattern.include?("*")
172
+ file_path = File.join(base_path, pattern)
173
+ paths << Models::CascadePath.new(
174
+ path: file_path,
175
+ priority: base_priority,
176
+ exists: false,
177
+ type: type
178
+ )
179
+ end
180
+
181
+ paths
182
+ end
183
+
184
+ # Find gem's default configs
185
+ # @return [Array<Models::CascadePath>] Gem default paths
186
+ def find_gem_defaults
187
+ return nil unless @gem_path
188
+
189
+ defaults_path = File.join(@gem_path, @defaults_dir)
190
+ return nil unless Dir.exist?(defaults_path)
191
+
192
+ paths = []
193
+
194
+ @file_patterns.each_with_index do |pattern, index|
195
+ Dir.glob(File.join(defaults_path, pattern)).each do |file|
196
+ next unless File.file?(file)
197
+
198
+ paths << Models::CascadePath.new(
199
+ path: file,
200
+ priority: 2000 + index, # Very low priority for gem defaults
201
+ exists: true,
202
+ type: :gem
203
+ )
204
+ end
205
+ end
206
+
207
+ paths.empty? ? nil : paths
208
+ end
209
+
210
+ # Determine config type from path
211
+ # @param path [String] Path to check
212
+ # @return [Symbol] Config type
213
+ def determine_type(path)
214
+ expanded = Ace::Support::Fs::Atoms::PathExpander.expand(path)
215
+ home = Ace::Support::Fs::Atoms::PathExpander.expand("~")
216
+
217
+ # Use @start_path (stable) instead of Dir.pwd (mutable)
218
+ if expanded.start_with?(@start_path)
219
+ :local
220
+ elsif expanded.start_with?(home)
221
+ :home
222
+ else
223
+ :gem
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end